first prototype
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
border-color 0.15s,
|
||||
transform 0.1s,
|
||||
opacity 0.15s;
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: 2px solid var(--gold);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.md {
|
||||
padding: 9px 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.lg {
|
||||
padding: 13px 24px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: linear-gradient(180deg, var(--accent-strong), var(--accent));
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgb(139 108 255 / 35%);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.secondary:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
background: rgb(255 255 255 / 6%);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: transparent;
|
||||
border-color: rgb(255 107 122 / 50%);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.danger:hover:not(:disabled) {
|
||||
background: rgb(255 107 122 / 12%);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
import styles from './Button.module.css'
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', size = 'md', className, type = 'button', ...props }: ButtonProps) {
|
||||
const classes = [styles.button, styles[variant], styles[size], className].filter(Boolean).join(' ')
|
||||
return <button type={type} className={classes} {...props} />
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
background: rgb(5 4 15 / 70%);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(460px, 100%);
|
||||
max-height: calc(100dvh - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.wide {
|
||||
width: min(900px, 100%);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.close {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 16px 20px 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import styles from './Modal.module.css'
|
||||
|
||||
interface ModalProps {
|
||||
title: string
|
||||
onClose?: () => void
|
||||
children: ReactNode
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
export function Modal({ title, onClose, children, wide }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!onClose) return
|
||||
const onKey = (event: KeyboardEvent) => event.key === 'Escape' && onClose()
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className={styles.backdrop} onClick={onClose}>
|
||||
<div
|
||||
className={`${styles.dialog} ${wide ? styles.wide : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
{onClose && (
|
||||
<button type="button" className={styles.close} onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
<div className={styles.body}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
.card {
|
||||
--card-color: #666;
|
||||
--w: 72px;
|
||||
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: var(--w);
|
||||
aspect-ratio: 5 / 7.4;
|
||||
padding: 0;
|
||||
border: 2px solid var(--card-color);
|
||||
border-radius: calc(var(--w) * 0.12);
|
||||
background:
|
||||
linear-gradient(160deg, rgb(255 255 255 / 100%), rgb(240 238 250 / 100%));
|
||||
color: var(--card-color);
|
||||
font-family: var(--font);
|
||||
font-weight: 800;
|
||||
box-shadow: 0 3px 8px rgb(0 0 0 / 35%);
|
||||
user-select: none;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
opacity 0.15s ease,
|
||||
filter 0.15s ease;
|
||||
}
|
||||
|
||||
.sm {
|
||||
--w: 44px;
|
||||
}
|
||||
|
||||
.md {
|
||||
--w: 72px;
|
||||
}
|
||||
|
||||
.lg {
|
||||
--w: 88px;
|
||||
}
|
||||
|
||||
.red {
|
||||
--card-color: var(--suit-red);
|
||||
}
|
||||
|
||||
.yellow {
|
||||
--card-color: var(--suit-yellow);
|
||||
}
|
||||
|
||||
.green {
|
||||
--card-color: var(--suit-green);
|
||||
}
|
||||
|
||||
.blue {
|
||||
--card-color: var(--suit-blue);
|
||||
}
|
||||
|
||||
.wizard {
|
||||
--card-color: #fff;
|
||||
|
||||
border-color: #c9b8ff;
|
||||
background: radial-gradient(circle at 50% 35%, #8b6cff, #3b1f9e 75%);
|
||||
}
|
||||
|
||||
.jester {
|
||||
--card-color: #3d3a52;
|
||||
|
||||
border-color: #9b98b3;
|
||||
background: linear-gradient(160deg, #e9e7f2, #c9c6d8);
|
||||
}
|
||||
|
||||
.back {
|
||||
border-color: #564aa8;
|
||||
background:
|
||||
repeating-linear-gradient(45deg, rgb(255 255 255 / 7%) 0 4px, transparent 4px 9px),
|
||||
linear-gradient(160deg, #3b2f8f, #211a58);
|
||||
}
|
||||
|
||||
.corner {
|
||||
position: absolute;
|
||||
top: 4%;
|
||||
left: 8%;
|
||||
font-size: calc(var(--w) * 0.2);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cornerBottom {
|
||||
top: auto;
|
||||
right: 8%;
|
||||
bottom: 4%;
|
||||
left: auto;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: calc(var(--w) * 0.46);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
font-size: calc(var(--w) * 0.5);
|
||||
text-shadow: 0 0 12px rgb(255 209 102 / 60%);
|
||||
}
|
||||
|
||||
.wizard .glyph {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.sm .corner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playable {
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0 0 2px rgb(255 209 102 / 55%),
|
||||
0 3px 8px rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.playable:hover,
|
||||
.playable:focus-visible {
|
||||
transform: translateY(-12px);
|
||||
box-shadow:
|
||||
0 0 0 3px var(--gold),
|
||||
0 10px 20px rgb(0 0 0 / 45%);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dimmed {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(0.6) brightness(0.6);
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
box-shadow:
|
||||
0 0 0 3px var(--gold),
|
||||
0 0 24px rgb(255 209 102 / 55%);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Card } from '../../api/types'
|
||||
import { cardLabel } from '../../util/cards'
|
||||
import styles from './PlayingCard.module.css'
|
||||
|
||||
interface PlayingCardProps {
|
||||
card: Card
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
// Clickable card in the hand.
|
||||
onClick?: () => void
|
||||
// Rendered dimmed, e.g. a card that must not be played now.
|
||||
dimmed?: boolean
|
||||
highlighted?: boolean
|
||||
}
|
||||
|
||||
export function PlayingCard({ card, size = 'md', onClick, dimmed, highlighted }: PlayingCardProps) {
|
||||
const kindClass = card.kind === 'number' ? styles[card.suit!] : styles[card.kind]
|
||||
const classes = [
|
||||
styles.card,
|
||||
styles[size],
|
||||
kindClass,
|
||||
onClick && !dimmed ? styles.playable : '',
|
||||
dimmed ? styles.dimmed : '',
|
||||
highlighted ? styles.highlighted : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const symbol = card.kind === 'wizard' ? 'W' : card.kind === 'jester' ? 'J' : String(card.rank)
|
||||
const content = (
|
||||
<>
|
||||
<span className={styles.corner}>{symbol}</span>
|
||||
<span className={styles.center}>
|
||||
{card.kind === 'wizard' && <span className={styles.glyph}>✦</span>}
|
||||
{card.kind === 'jester' && <span className={styles.glyph}>☾</span>}
|
||||
{card.kind === 'number' && symbol}
|
||||
</span>
|
||||
<span className={`${styles.corner} ${styles.cornerBottom}`}>{symbol}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" className={classes} onClick={onClick} disabled={dimmed} aria-label={cardLabel(card)} title={cardLabel(card)}>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={classes} role="img" aria-label={cardLabel(card)} title={cardLabel(card)}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardBack({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
||||
return <div className={`${styles.card} ${styles[size]} ${styles.back}`} aria-hidden="true" />
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgb(255 107 122 / 50%);
|
||||
border-radius: 10px;
|
||||
background: #2a1426;
|
||||
color: #ffd9de;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
animation: slide-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Toast } from '../../api/useWizard'
|
||||
import styles from './Toasts.module.css'
|
||||
|
||||
interface ToastsProps {
|
||||
toasts: Toast[]
|
||||
onDismiss: (id: number) => void
|
||||
}
|
||||
|
||||
export function Toasts({ toasts, onDismiss }: ToastsProps) {
|
||||
return (
|
||||
<div className={styles.container} aria-live="polite">
|
||||
{toasts.map((toast) => (
|
||||
<button key={toast.id} type="button" className={styles.toast} onClick={() => onDismiss(toast.id)}>
|
||||
{toast.message}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user