) {
+ const delta = step[event.key]
+ let next: number
+ if (delta !== undefined) next = (selected + delta + options.length) % options.length
+ else if (event.key === 'Home') next = 0
+ else if (event.key === 'End') next = options.length - 1
+ else return
+ event.preventDefault()
+ select(next)
+ }
+
+ return (
+
+ {options.map((option, index) => {
+ const checked = index === selected
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/web/src/ui/Slider.module.css b/web/src/ui/Slider.module.css
new file mode 100644
index 0000000..a557abf
--- /dev/null
+++ b/web/src/ui/Slider.module.css
@@ -0,0 +1,61 @@
+/* The input is the 44 px tall hit area; the track and knob are drawn by the
+ vendor pseudo-elements. --slider-fill is set inline from the value. */
+.slider {
+ appearance: none;
+ display: block;
+ width: 100%;
+ min-width: 44px;
+ height: 44px;
+ margin: 0;
+ background: transparent;
+ cursor: pointer;
+}
+
+.slider:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.slider::-webkit-slider-runnable-track {
+ height: 8px;
+ border-radius: var(--radius-pill);
+ background: linear-gradient(
+ to right,
+ var(--color-accent) var(--slider-fill),
+ var(--color-neutral-300) var(--slider-fill)
+ );
+}
+
+.slider::-webkit-slider-thumb {
+ appearance: none;
+ box-sizing: border-box;
+ width: 24px;
+ height: 24px;
+ margin-top: -8px;
+ border: 3px solid var(--color-accent);
+ border-radius: var(--radius-pill);
+ background: var(--color-bg);
+ box-shadow: var(--shadow-sm);
+}
+
+.slider::-moz-range-track {
+ height: 8px;
+ border-radius: var(--radius-pill);
+ background: var(--color-neutral-300);
+}
+
+.slider::-moz-range-progress {
+ height: 8px;
+ border-radius: var(--radius-pill);
+ background: var(--color-accent);
+}
+
+.slider::-moz-range-thumb {
+ box-sizing: border-box;
+ width: 24px;
+ height: 24px;
+ border: 3px solid var(--color-accent);
+ border-radius: var(--radius-pill);
+ background: var(--color-bg);
+ box-shadow: var(--shadow-sm);
+}
diff --git a/web/src/ui/Slider.test.tsx b/web/src/ui/Slider.test.tsx
new file mode 100644
index 0000000..42a89bb
--- /dev/null
+++ b/web/src/ui/Slider.test.tsx
@@ -0,0 +1,50 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { useState } from 'react'
+import { describe, expect, it } from 'vitest'
+import { expectMinHitArea } from '../test/hitArea.ts'
+import { Slider } from './Slider.tsx'
+
+function Harness() {
+ const [value, setValue] = useState(0.7)
+ return (
+ setValue(Number(event.target.value))}
+ />
+ )
+}
+
+describe('Slider', () => {
+ it('is a native slider exposing its range and value', () => {
+ render()
+ const slider = screen.getByRole('slider', { name: 'Creativity (temperature)' })
+ expect(slider).toHaveAttribute('type', 'range')
+ expect(slider).toHaveAttribute('min', '0')
+ expect(slider).toHaveAttribute('max', '1')
+ expect(slider).toHaveValue('0.7')
+ })
+
+ it('updates the grape fill with the value', () => {
+ render()
+ const slider = screen.getByRole('slider')
+ expect(slider.style.getPropertyValue('--slider-fill')).toBe('70%')
+ fireEvent.change(slider, { target: { value: '0.2' } })
+ expect(slider).toHaveValue('0.2')
+ expect(slider.style.getPropertyValue('--slider-fill')).toBe('20%')
+ })
+
+ it('has a ≥ 44 px hit area', () => {
+ render()
+ expectMinHitArea(screen.getByRole('slider'))
+ })
+
+ it('fails fast on an empty range', () => {
+ expect(() => render()).toThrow(
+ 'max (1) must be greater than min (1)',
+ )
+ })
+})
diff --git a/web/src/ui/Slider.tsx b/web/src/ui/Slider.tsx
new file mode 100644
index 0000000..35a35c7
--- /dev/null
+++ b/web/src/ui/Slider.tsx
@@ -0,0 +1,27 @@
+import type { CSSProperties, ComponentPropsWithRef } from 'react'
+import styles from './Slider.module.css'
+
+export type SliderProps = Omit, 'type' | 'value' | 'min' | 'max'> & {
+ value: number
+ min: number
+ max: number
+}
+
+// Native range input, styled: 8 px track, grape fill up to the value, 24 px
+// knob with a 3 px grape ring. Controlled, because the fill follows `value`.
+// Name it with aria-label or aria-labelledby.
+export function Slider({ value, min, max, className, style, ...rest }: SliderProps) {
+ if (!(max > min)) throw new Error(`Slider: max (${max}) must be greater than min (${min})`)
+ const fill = `${((value - min) / (max - min)) * 100}%`
+ return (
+
+ )
+}
diff --git a/web/src/ui/Tag.module.css b/web/src/ui/Tag.module.css
new file mode 100644
index 0000000..a3df5e4
--- /dev/null
+++ b/web/src/ui/Tag.module.css
@@ -0,0 +1,21 @@
+.tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 10px;
+ border-radius: var(--radius-pill);
+ font-family: var(--font-body);
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1.2;
+ white-space: nowrap;
+}
+
+.grape {
+ background: var(--color-accent-100);
+ color: var(--color-accent-800);
+}
+
+.teal {
+ background: var(--color-accent-2-200);
+ color: var(--color-accent-2-800);
+}
diff --git a/web/src/ui/Tag.test.tsx b/web/src/ui/Tag.test.tsx
new file mode 100644
index 0000000..8c6f795
--- /dev/null
+++ b/web/src/ui/Tag.test.tsx
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { Tag } from './Tag.tsx'
+import styles from './Tag.module.css'
+
+describe('Tag', () => {
+ it('renders a static, non-interactive label', () => {
+ render(Draft)
+ const tag = screen.getByText('Draft')
+ expect(tag).toHaveClass(styles.grape!)
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ })
+
+ it('supports the teal tone', () => {
+ render(Synced)
+ expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
+ })
+})
diff --git a/web/src/ui/Tag.tsx b/web/src/ui/Tag.tsx
new file mode 100644
index 0000000..5d26c95
--- /dev/null
+++ b/web/src/ui/Tag.tsx
@@ -0,0 +1,14 @@
+import type { ComponentPropsWithRef } from 'react'
+import styles from './Tag.module.css'
+
+export type TagTone = 'grape' | 'teal'
+
+export type TagProps = ComponentPropsWithRef<'span'> & {
+ tone?: TagTone
+}
+
+// Static label such as "Draft", "Side quest" or "Synced". Not interactive.
+export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
+ const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
+ return
+}
diff --git a/web/src/ui/TextInput.module.css b/web/src/ui/TextInput.module.css
new file mode 100644
index 0000000..e44b927
--- /dev/null
+++ b/web/src/ui/TextInput.module.css
@@ -0,0 +1,46 @@
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.label {
+ font-family: var(--font-body);
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--color-neutral-700);
+}
+
+.hiddenLabel {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+
+.input {
+ box-sizing: border-box;
+ min-width: 44px;
+ height: 48px;
+ padding: 0 20px;
+ border: 1px solid var(--color-neutral-300);
+ border-radius: var(--radius-pill);
+ background: var(--color-surface);
+ color: var(--color-text);
+ font-family: var(--font-body);
+ font-size: 16px;
+}
+
+.input::placeholder {
+ color: var(--color-neutral-700);
+}
+
+.input:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
diff --git a/web/src/ui/TextInput.test.tsx b/web/src/ui/TextInput.test.tsx
new file mode 100644
index 0000000..f4c7a6b
--- /dev/null
+++ b/web/src/ui/TextInput.test.tsx
@@ -0,0 +1,34 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { createRef } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
+import { TextInput } from './TextInput.tsx'
+
+describe('TextInput', () => {
+ it('is a textbox named by its label', () => {
+ const onChange = vi.fn()
+ render()
+ const input = screen.getByRole('textbox', { name: 'Dinner name' })
+ fireEvent.change(input, { target: { value: 'Chili' } })
+ expect(onChange).toHaveBeenCalledOnce()
+ expect(input).toHaveValue('Chili')
+ })
+
+ it('keeps the accessible name when the label is visually hidden', () => {
+ render()
+ expect(screen.getByRole('textbox', { name: 'Add a regular' })).toBeInTheDocument()
+ })
+
+ it('is a 48 px pill with a ≥ 44 px hit area', () => {
+ render()
+ const input = screen.getByRole('textbox', { name: 'Dinner name' })
+ expectMinHitArea(input)
+ expect(hitArea(input).height).toBe(48)
+ })
+
+ it('forwards the ref', () => {
+ const ref = createRef()
+ render()
+ expect(ref.current).toBe(screen.getByRole('textbox', { name: 'Dinner name' }))
+ })
+})
diff --git a/web/src/ui/TextInput.tsx b/web/src/ui/TextInput.tsx
new file mode 100644
index 0000000..fd6ea01
--- /dev/null
+++ b/web/src/ui/TextInput.tsx
@@ -0,0 +1,21 @@
+import { useId, type ComponentPropsWithRef } from 'react'
+import styles from './TextInput.module.css'
+
+export type TextInputProps = Omit, 'id'> & {
+ label: string
+ // Hide the label visually; it stays the input's accessible name.
+ labelHidden?: boolean
+}
+
+// 48 px pill input on the surface fill, always associated with its label.
+export function TextInput({ label, labelHidden = false, type = 'text', className, ...rest }: TextInputProps) {
+ const id = useId()
+ return (
+
+
+
+
+ )
+}
diff --git a/web/src/ui/Toggle.module.css b/web/src/ui/Toggle.module.css
new file mode 100644
index 0000000..ddcd414
--- /dev/null
+++ b/web/src/ui/Toggle.module.css
@@ -0,0 +1,57 @@
+/* The button is a 44 px hit box around the 40×24 track; negative margins keep
+ the 40×24 layout footprint. */
+.toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ width: 44px;
+ height: 44px;
+ margin: -10px -2px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--radius-pill);
+ background: transparent;
+ cursor: pointer;
+}
+
+.toggle:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.track {
+ position: relative;
+ width: 40px;
+ height: 24px;
+ border-radius: var(--radius-pill);
+ background: var(--color-neutral-300);
+ transition: background-color 150ms var(--ease-out-soft);
+}
+
+.knob {
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ width: 18px;
+ height: 18px;
+ border-radius: var(--radius-pill);
+ background: var(--color-bg);
+ box-shadow: var(--shadow-sm);
+ transition: transform 180ms var(--ease-spring);
+}
+
+.toggle[aria-checked='true'] .track {
+ background: var(--color-accent-2-600);
+}
+
+.toggle[aria-checked='true'] .knob {
+ transform: translateX(16px);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .track,
+ .knob {
+ transition: none;
+ }
+}
diff --git a/web/src/ui/Toggle.test.tsx b/web/src/ui/Toggle.test.tsx
new file mode 100644
index 0000000..8237227
--- /dev/null
+++ b/web/src/ui/Toggle.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { useState } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
+import { Toggle } from './Toggle.tsx'
+
+function Harness() {
+ const [checked, setChecked] = useState(false)
+ return
+}
+
+function toggle() {
+ return screen.getByRole('switch', { name: 'Push: New weekly plan ready' })
+}
+
+describe('Toggle', () => {
+ it('is a switch reflecting its state in aria-checked', () => {
+ render()
+ expect(toggle()).toHaveAttribute('aria-checked', 'false')
+ fireEvent.click(toggle())
+ expect(toggle()).toHaveAttribute('aria-checked', 'true')
+ fireEvent.click(toggle())
+ expect(toggle()).toHaveAttribute('aria-checked', 'false')
+ })
+
+ it.each([' ', 'Enter'])('toggles with the %j key', (key) => {
+ render()
+ fireEvent.keyDown(toggle(), { key })
+ expect(toggle()).toHaveAttribute('aria-checked', 'true')
+ fireEvent.keyDown(toggle(), { key })
+ expect(toggle()).toHaveAttribute('aria-checked', 'false')
+ })
+
+ it('suppresses native activation for Space and Enter so a key press toggles once', () => {
+ render()
+ expect(fireEvent.keyDown(toggle(), { key: ' ' })).toBe(false)
+ expect(fireEvent.keyUp(toggle(), { key: ' ' })).toBe(false)
+ expect(fireEvent.keyDown(toggle(), { key: 'Enter' })).toBe(false)
+ expect(fireEvent.keyDown(toggle(), { key: 'a' })).toBe(true)
+ })
+
+ it('ignores other keys', () => {
+ const onCheckedChange = vi.fn()
+ render()
+ fireEvent.keyDown(screen.getByRole('switch', { name: 'Chat' }), { key: 'a' })
+ expect(onCheckedChange).not.toHaveBeenCalled()
+ })
+
+ it('is disabled when asked', () => {
+ const onCheckedChange = vi.fn()
+ render()
+ const sw = screen.getByRole('switch', { name: 'Chat' })
+ expect(sw).toBeDisabled()
+ fireEvent.click(sw)
+ expect(onCheckedChange).not.toHaveBeenCalled()
+ })
+
+ it('draws a 40×24 track inside a ≥ 44 px hit area', () => {
+ render()
+ expectMinHitArea(toggle())
+ expect(hitArea(toggle().firstElementChild!)).toEqual({ width: 40, height: 24 })
+ })
+})
diff --git a/web/src/ui/Toggle.tsx b/web/src/ui/Toggle.tsx
new file mode 100644
index 0000000..e45979b
--- /dev/null
+++ b/web/src/ui/Toggle.tsx
@@ -0,0 +1,42 @@
+import type { ComponentPropsWithRef, KeyboardEvent } from 'react'
+import styles from './Toggle.module.css'
+
+export type ToggleProps = Omit<
+ ComponentPropsWithRef<'button'>,
+ 'type' | 'role' | 'aria-checked' | 'onChange' | 'onClick' | 'onKeyDown' | 'onKeyUp' | 'children'
+> & {
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+}
+
+// 40×24 pill switch (role="switch"). Name it with aria-label or
+// aria-labelledby. Space and Enter are handled here and their native button
+// activation is suppressed, so each key press toggles exactly once.
+export function Toggle({ checked, onCheckedChange, className, ...rest }: ToggleProps) {
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key !== ' ' && event.key !== 'Enter') return
+ event.preventDefault()
+ onCheckedChange(!checked)
+ }
+
+ function onKeyUp(event: KeyboardEvent) {
+ if (event.key === ' ') event.preventDefault()
+ }
+
+ return (
+
+ )
+}
diff --git a/web/src/ui/index.ts b/web/src/ui/index.ts
new file mode 100644
index 0000000..f0e6b63
--- /dev/null
+++ b/web/src/ui/index.ts
@@ -0,0 +1,11 @@
+export { Button, type ButtonProps, type ButtonVariant } from './Button.tsx'
+export { Card, type CardProps, type CardTone } from './Card.tsx'
+export { Checkbox, type CheckboxProps } from './Checkbox.tsx'
+export { Chip, type ChipProps } from './Chip.tsx'
+export { IconButton, type IconButtonProps, type IconButtonSize, type IconButtonTone } from './IconButton.tsx'
+export { ListRow, type ListRowProps } from './ListRow.tsx'
+export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx'
+export { Slider, type SliderProps } from './Slider.tsx'
+export { Tag, type TagProps, type TagTone } from './Tag.tsx'
+export { TextInput, type TextInputProps } from './TextInput.tsx'
+export { Toggle, type ToggleProps } from './Toggle.tsx'
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 1a9192b..09250bb 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -14,6 +14,9 @@ export default defineConfig({
},
test: {
environment: 'jsdom',
+ // Inject CSS Modules into jsdom so tests can assert the sizes the
+ // stylesheets actually declare (for example 44 px hit areas).
+ css: true,
setupFiles: ['./src/test/setup.ts'],
},
})