first prototype

This commit is contained in:
2026-09-27 09:52:00 +02:00
parent 4635199a41
commit 6d011d474f
109 changed files with 6354 additions and 507 deletions
@@ -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>
)
}