From 496f2e5375b35580be818e749d0eadbf4aefd54e Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 16:16:25 -0400 Subject: [PATCH 1/8] Feature 05 task 1: Design tokens and self-hosted fonts (#32) Co-authored-by: dev_agent Co-committed-by: dev_agent --- web/package.json | 6 +- web/scripts/check-dist-fonts.mjs | 32 +++++ web/scripts/lint-colors.mjs | 42 +++++++ web/src/assets/fonts/BagelFatOne-Latin.woff2 | Bin 0 -> 24376 bytes .../assets/fonts/BagelFatOne-LatinExt.woff2 | Bin 0 -> 5048 bytes web/src/assets/fonts/BagelFatOne-OFL.txt | 93 ++++++++++++++ web/src/assets/fonts/Figtree-Bold.woff2 | Bin 0 -> 23620 bytes web/src/assets/fonts/Figtree-OFL.txt | 93 ++++++++++++++ web/src/assets/fonts/Figtree-Regular.woff2 | Bin 0 -> 23412 bytes web/src/assets/fonts/Figtree-SemiBold.woff2 | Bin 0 -> 23580 bytes web/src/main.tsx | 2 + web/src/styles/base.css | 11 ++ web/src/styles/tokens.css | 116 ++++++++++++++++++ 13 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 web/scripts/check-dist-fonts.mjs create mode 100644 web/scripts/lint-colors.mjs create mode 100644 web/src/assets/fonts/BagelFatOne-Latin.woff2 create mode 100644 web/src/assets/fonts/BagelFatOne-LatinExt.woff2 create mode 100644 web/src/assets/fonts/BagelFatOne-OFL.txt create mode 100644 web/src/assets/fonts/Figtree-Bold.woff2 create mode 100644 web/src/assets/fonts/Figtree-OFL.txt create mode 100644 web/src/assets/fonts/Figtree-Regular.woff2 create mode 100644 web/src/assets/fonts/Figtree-SemiBold.woff2 create mode 100644 web/src/styles/base.css create mode 100644 web/src/styles/tokens.css diff --git a/web/package.json b/web/package.json index 3ea1eed..e5bf6d4 100644 --- a/web/package.json +++ b/web/package.json @@ -5,10 +5,12 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build", + "build": "tsc --noEmit && vite build && npm run check:dist-fonts", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "npm run lint:colors && vitest run", + "lint:colors": "node scripts/lint-colors.mjs", + "check:dist-fonts": "node scripts/check-dist-fonts.mjs" }, "dependencies": { "@tanstack/react-query": "^5.104.1", diff --git a/web/scripts/check-dist-fonts.mjs b/web/scripts/check-dist-fonts.mjs new file mode 100644 index 0000000..fa9d2cd --- /dev/null +++ b/web/scripts/check-dist-fonts.mjs @@ -0,0 +1,32 @@ +// Fails when the production build references Google Fonts: fonts are +// self-hosted, so dist must never point at fonts.googleapis.com or +// fonts.gstatic.com. +import { readFileSync, readdirSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const distDir = fileURLToPath(new URL('../dist', import.meta.url)) +const hosts = ['fonts.googleapis.com', 'fonts.gstatic.com'] + +function* files(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) yield* files(path) + else yield path + } +} + +const violations = [] +for (const path of files(distDir)) { + const content = readFileSync(path, 'latin1') + for (const host of hosts) { + if (content.includes(host)) violations.push(`${relative(distDir, path)}: ${host}`) + } +} + +if (violations.length > 0) { + console.error('Production build references Google Fonts; fonts must be self-hosted:') + for (const v of violations) console.error(` ${v}`) + process.exit(1) +} +console.log('check:dist-fonts: dist makes no Google Fonts requests') diff --git a/web/scripts/lint-colors.mjs b/web/scripts/lint-colors.mjs new file mode 100644 index 0000000..e6ed7e5 --- /dev/null +++ b/web/scripts/lint-colors.mjs @@ -0,0 +1,42 @@ +// Fails when a hex colour literal appears in any CSS or TypeScript file under +// web/ outside the design tokens: every colour must come from a custom +// property in tokens.css. Dependencies, build output and hidden directories +// (tool caches) are not source and are skipped. +import { readFileSync, readdirSync } from 'node:fs' +import { extname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const webRoot = fileURLToPath(new URL('..', import.meta.url)) +const allowed = new Set(['src/styles/tokens.css']) +const skippedDirs = new Set([join(webRoot, 'node_modules'), join(webRoot, 'dist')]) +const extensions = new Set(['.css', '.ts', '.tsx']) +const hexColour = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g + +function* sourceFiles(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + if (!skippedDirs.has(path) && !entry.name.startsWith('.')) yield* sourceFiles(path) + } else if (extensions.has(extname(entry.name))) yield path + } +} + +const violations = [] +for (const path of sourceFiles(webRoot)) { + const name = relative(webRoot, path).split('\\').join('/') + if (allowed.has(name)) continue + readFileSync(path, 'utf8') + .split('\n') + .forEach((line, i) => { + for (const match of line.matchAll(hexColour)) { + violations.push(`${name}:${i + 1}: ${match[0]}`) + } + }) +} + +if (violations.length > 0) { + console.error('Hex colour literals found; use a token from src/styles/tokens.css:') + for (const v of violations) console.error(` ${v}`) + process.exit(1) +} +console.log('lint:colors: no hex colour literals outside src/styles/tokens.css') diff --git a/web/src/assets/fonts/BagelFatOne-Latin.woff2 b/web/src/assets/fonts/BagelFatOne-Latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..bd963048947c0090529e062f3b47838027e159c8 GIT binary patch literal 24376 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 D>B?1T literal 0 HcmV?d00001 diff --git a/web/src/assets/fonts/BagelFatOne-LatinExt.woff2 b/web/src/assets/fonts/BagelFatOne-LatinExt.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f3bd5008a0be9020ccdf8388eaa064bcca21f0d3 GIT binary patch literal 5048 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 O4aPIRmsVv2TLA#@xwvNl literal 0 HcmV?d00001 diff --git a/web/src/assets/fonts/BagelFatOne-OFL.txt b/web/src/assets/fonts/BagelFatOne-OFL.txt new file mode 100644 index 0000000..21c7b2d --- /dev/null +++ b/web/src/assets/fonts/BagelFatOne-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Bagel Fat Project Authors (https://github.com/JAMO-TYPEFACE/BagelFat) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/assets/fonts/Figtree-Bold.woff2 b/web/src/assets/fonts/Figtree-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..81bcca4c7412016dc1e59c1d03aa0dbcda755990 GIT binary patch literal 23620 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 literal 0 HcmV?d00001 diff --git a/web/src/assets/fonts/Figtree-OFL.txt b/web/src/assets/fonts/Figtree-OFL.txt new file mode 100644 index 0000000..80b12bb --- /dev/null +++ b/web/src/assets/fonts/Figtree-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/src/assets/fonts/Figtree-Regular.woff2 b/web/src/assets/fonts/Figtree-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2c1165fb81cf8f4335586fb11d9f4755f215f3ad GIT binary patch literal 23412 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 literal 0 HcmV?d00001 diff --git a/web/src/assets/fonts/Figtree-SemiBold.woff2 b/web/src/assets/fonts/Figtree-SemiBold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8855ccb99956d6313585bfa1f1b87676810c1211 GIT binary patch literal 23580 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 DUuGa3 literal 0 HcmV?d00001 diff --git a/web/src/main.tsx b/web/src/main.tsx index 1a842db..a2dcd7b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,8 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router' +import './styles/tokens.css' +import './styles/base.css' import App from './App.tsx' const root = document.getElementById('root') diff --git a/web/src/styles/base.css b/web/src/styles/base.css new file mode 100644 index 0000000..ac545c1 --- /dev/null +++ b/web/src/styles/base.css @@ -0,0 +1,11 @@ +/* App-wide base styles. Values come only from tokens.css. */ +body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-body); +} + +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 0000000..167806b --- /dev/null +++ b/web/src/styles/tokens.css @@ -0,0 +1,116 @@ +/* MealQuest design tokens — final palette "B4: Grape + teal blue on off-white". + Built on the Organic design system (styles.css in this bundle); these values OVERRIDE its defaults. */ +/* Fonts are self-hosted from web/src/assets/fonts (OFL; licence texts alongside) + in place of the handoff's Google Fonts @import, so the app never calls Google. + Bagel Fat One: Google Fonts latin and latin-ext woff2 subsets. + Figtree: upstream static woff2 (github.com/erikdkennedy/figtree). */ +@font-face { + font-family: "Bagel Fat One"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../assets/fonts/BagelFatOne-LatinExt.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Bagel Fat One"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../assets/fonts/BagelFatOne-Latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../assets/fonts/Figtree-Regular.woff2") format("woff2"); +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../assets/fonts/Figtree-SemiBold.woff2") format("woff2"); +} +@font-face { + font-family: "Figtree"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../assets/fonts/Figtree-Bold.woff2") format("woff2"); +} + +:root { + /* Ground + ink */ + --color-bg: #f9f6f1; /* oklch(0.975 0.008 85) — page / phone background */ + --color-surface: #eee9de; /* oklch(0.935 0.016 85) — cards, rows, inputs */ + --color-text: #201e1d; + --color-divider: color-mix(in srgb, #201e1d 16%, transparent); + --on-accent: #f9f6f1; /* text/icons on --color-accent fills (never dark text on grape) */ + + /* Primary accent — Grape (hue 300) */ + --color-accent: #5a358c; /* oklch(0.42 0.14 300) */ + --color-accent-100: #f8f1ff; + --color-accent-200: #eedfff; + --color-accent-300: #dac3ff; + --color-accent-400: #c1a1f9; + --color-accent-500: #a47ee1; + --color-accent-600: #865fc0; + --color-accent-700: #68449b; /* accent-colored small text on light grounds */ + --color-accent-800: #4a2d73; + --color-accent-900: #2d1a49; + + /* Second accent — Teal blue (hue 215) */ + --color-accent-2: #29889c; /* oklch(0.58 0.09 215) */ + --color-accent-2-100: #e0fbff; + --color-accent-2-200: #bbf3ff; + --color-accent-2-300: #88e0f6; + --color-accent-2-400: #3fc7e4; + --color-accent-2-500: #00a9ca; + --color-accent-2-600: #008aaa; /* toggles ON, "Try it" button, + buttons */ + --color-accent-2-700: #006b88; + --color-accent-2-800: #004c63; /* teal text on teal-100/200 fills */ + --color-accent-2-900: #002f3e; + + /* Neutrals (unchanged from Organic) */ + --color-neutral-100: #f9f4ed; + --color-neutral-200: #eee7db; + --color-neutral-300: #dcd3c4; + --color-neutral-400: #c0b6a5; + --color-neutral-500: #a19786; + --color-neutral-600: #82796a; + --color-neutral-700: #645c50; + --color-neutral-800: #474238; + --color-neutral-900: #2e2b25; + + /* Type */ + --font-heading: "Bagel Fat One", system-ui, sans-serif; /* weight 400 only */ + --font-body: "Figtree", system-ui, sans-serif; /* 400 / 600 / 700 */ + --font-mono: ui-monospace, Menlo, monospace; + + /* Radii */ + --radius-sm: 8px; + --radius-md: 16px; + --radius-lg: 28px; + --radius-card: 32px; /* mobile hero cards */ + --radius-pill: 999px; /* buttons, inputs, tags, nav */ + + /* Spacing (Organic, density 1.1) */ + --space-1: 4.4px; --space-2: 8.8px; --space-3: 13.2px; --space-4: 17.6px; --space-6: 26.4px; --space-8: 35.2px; + + /* Elevation */ + --shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent); + --shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent); + --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); + + /* Motion */ + --ease-spring: cubic-bezier(.34, 1.4, .64, 1); /* nav pill, snap-back, bumps */ + --ease-out-soft: cubic-bezier(.2, .8, .2, 1); /* card promotion */ + --ease-throw: cubic-bezier(.55, 0, .8, .4); /* fly-off right */ +} + +@media (prefers-reduced-motion: reduce) { + :root { --motion-scale: 0; } +} -- 2.55.0 From 02f1dd6491a072fee429d6d5243dd33bb6a7d8e8 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 16:34:59 -0400 Subject: [PATCH 2/8] Feature 05 task 2: Core UI primitives (#34) Co-authored-by: dev_agent Co-committed-by: dev_agent --- web/src/test/hitArea.ts | 26 +++++++++ web/src/ui/Button.module.css | 63 ++++++++++++++++++++++ web/src/ui/Button.test.tsx | 53 +++++++++++++++++++ web/src/ui/Button.tsx | 16 ++++++ web/src/ui/Card.module.css | 13 +++++ web/src/ui/Card.test.tsx | 18 +++++++ web/src/ui/Card.tsx | 14 +++++ web/src/ui/Checkbox.module.css | 63 ++++++++++++++++++++++ web/src/ui/Checkbox.test.tsx | 36 +++++++++++++ web/src/ui/Checkbox.tsx | 17 ++++++ web/src/ui/Chip.module.css | 35 ++++++++++++ web/src/ui/Chip.test.tsx | 26 +++++++++ web/src/ui/Chip.tsx | 24 +++++++++ web/src/ui/IconButton.module.css | 74 ++++++++++++++++++++++++++ web/src/ui/IconButton.test.tsx | 34 ++++++++++++ web/src/ui/IconButton.tsx | 33 ++++++++++++ web/src/ui/ListRow.module.css | 69 ++++++++++++++++++++++++ web/src/ui/ListRow.test.tsx | 57 ++++++++++++++++++++ web/src/ui/ListRow.tsx | 45 ++++++++++++++++ web/src/ui/Segmented.module.css | 35 ++++++++++++ web/src/ui/Segmented.test.tsx | 91 ++++++++++++++++++++++++++++++++ web/src/ui/Segmented.tsx | 76 ++++++++++++++++++++++++++ web/src/ui/Slider.module.css | 61 +++++++++++++++++++++ web/src/ui/Slider.test.tsx | 69 ++++++++++++++++++++++++ web/src/ui/Slider.tsx | 30 +++++++++++ web/src/ui/Tag.module.css | 21 ++++++++ web/src/ui/Tag.test.tsx | 18 +++++++ web/src/ui/Tag.tsx | 14 +++++ web/src/ui/TextInput.module.css | 46 ++++++++++++++++ web/src/ui/TextInput.test.tsx | 34 ++++++++++++ web/src/ui/TextInput.tsx | 21 ++++++++ web/src/ui/Toggle.module.css | 57 ++++++++++++++++++++ web/src/ui/Toggle.test.tsx | 63 ++++++++++++++++++++++ web/src/ui/Toggle.tsx | 42 +++++++++++++++ web/src/ui/index.ts | 11 ++++ web/vite.config.ts | 3 ++ 36 files changed, 1408 insertions(+) create mode 100644 web/src/test/hitArea.ts create mode 100644 web/src/ui/Button.module.css create mode 100644 web/src/ui/Button.test.tsx create mode 100644 web/src/ui/Button.tsx create mode 100644 web/src/ui/Card.module.css create mode 100644 web/src/ui/Card.test.tsx create mode 100644 web/src/ui/Card.tsx create mode 100644 web/src/ui/Checkbox.module.css create mode 100644 web/src/ui/Checkbox.test.tsx create mode 100644 web/src/ui/Checkbox.tsx create mode 100644 web/src/ui/Chip.module.css create mode 100644 web/src/ui/Chip.test.tsx create mode 100644 web/src/ui/Chip.tsx create mode 100644 web/src/ui/IconButton.module.css create mode 100644 web/src/ui/IconButton.test.tsx create mode 100644 web/src/ui/IconButton.tsx create mode 100644 web/src/ui/ListRow.module.css create mode 100644 web/src/ui/ListRow.test.tsx create mode 100644 web/src/ui/ListRow.tsx create mode 100644 web/src/ui/Segmented.module.css create mode 100644 web/src/ui/Segmented.test.tsx create mode 100644 web/src/ui/Segmented.tsx create mode 100644 web/src/ui/Slider.module.css create mode 100644 web/src/ui/Slider.test.tsx create mode 100644 web/src/ui/Slider.tsx create mode 100644 web/src/ui/Tag.module.css create mode 100644 web/src/ui/Tag.test.tsx create mode 100644 web/src/ui/Tag.tsx create mode 100644 web/src/ui/TextInput.module.css create mode 100644 web/src/ui/TextInput.test.tsx create mode 100644 web/src/ui/TextInput.tsx create mode 100644 web/src/ui/Toggle.module.css create mode 100644 web/src/ui/Toggle.test.tsx create mode 100644 web/src/ui/Toggle.tsx create mode 100644 web/src/ui/index.ts diff --git a/web/src/test/hitArea.ts b/web/src/test/hitArea.ts new file mode 100644 index 0000000..fa0f903 --- /dev/null +++ b/web/src/test/hitArea.ts @@ -0,0 +1,26 @@ +import { expect } from 'vitest' + +// Minimum tap target from the handoff's accessibility rules. +export const MIN_HIT_AREA = 44 + +function px(value: string): number { + return value.endsWith('px') ? Number.parseFloat(value) : 0 +} + +// The tap target an element's stylesheet guarantees: the larger of its +// declared size and declared minimum size, per axis. jsdom does no layout, so +// this reads the CSS Modules' computed values (Vitest runs with css: true). +// A dimension the CSS leaves unsized counts as 0 and fails the check. +export function hitArea(element: Element): { width: number; height: number } { + const style = getComputedStyle(element) + return { + width: Math.max(px(style.width), px(style.minWidth)), + height: Math.max(px(style.height), px(style.minHeight)), + } +} + +export function expectMinHitArea(element: Element) { + const { width, height } = hitArea(element) + expect(width, 'hit area width').toBeGreaterThanOrEqual(MIN_HIT_AREA) + expect(height, 'hit area height').toBeGreaterThanOrEqual(MIN_HIT_AREA) +} diff --git a/web/src/ui/Button.module.css b/web/src/ui/Button.module.css new file mode 100644 index 0000000..8b9de26 --- /dev/null +++ b/web/src/ui/Button.module.css @@ -0,0 +1,63 @@ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-width: 44px; + min-height: 48px; + padding: 0 22px; + border: 1.5px solid transparent; + border-radius: var(--radius-pill); + font-family: var(--font-heading); + font-size: 15px; + font-weight: 400; + line-height: 1; + cursor: pointer; + transition: transform 100ms var(--ease-out-soft), background-color 100ms var(--ease-out-soft); +} + +.button:active:not(:disabled) { + transform: scale(0.97); +} + +.button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.primary { + background: var(--color-accent); + color: var(--on-accent); +} + +.primary:hover:not(:disabled), +.primary:active:not(:disabled) { + background: var(--color-accent-800); +} + +.outline { + background: transparent; + border-color: var(--color-neutral-300); + color: var(--color-text); +} + +.outline:hover:not(:disabled), +.outline:active:not(:disabled) { + background: var(--color-neutral-200); +} + +.dark { + background: var(--color-text); + color: var(--color-bg); +} + +.dark:hover:not(:disabled), +.dark:active:not(:disabled) { + background: var(--color-neutral-800); +} + +@media (prefers-reduced-motion: reduce) { + .button { + transition: none; + } +} diff --git a/web/src/ui/Button.test.tsx b/web/src/ui/Button.test.tsx new file mode 100644 index 0000000..4797e88 --- /dev/null +++ b/web/src/ui/Button.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { expectMinHitArea } from '../test/hitArea.ts' +import { Button } from './Button.tsx' +import styles from './Button.module.css' + +describe('Button', () => { + it('is a button that defaults to type="button" and the primary variant', () => { + render() + const button = screen.getByRole('button', { name: 'Save changes' }) + expect(button).toHaveAttribute('type', 'button') + expect(button).toHaveClass(styles.button!, styles.primary!) + }) + + it.each(['primary', 'outline', 'dark'] as const)('renders the %s variant with a ≥ 44 px hit area', (variant) => { + render() + const button = screen.getByRole('button', { name: 'Go' }) + expect(button).toHaveClass(styles[variant]!) + expectMinHitArea(button) + }) + + it('declares the 100 ms pressed scale transition', () => { + render() + expect(getComputedStyle(screen.getByRole('button')).transition).toContain('transform 100ms') + }) + + it('does not fire onClick when disabled', () => { + const onClick = vi.fn() + render( + , + ) + const button = screen.getByRole('button', { name: 'Go' }) + expect(button).toBeDisabled() + fireEvent.click(button) + expect(onClick).not.toHaveBeenCalled() + }) + + it('forwards the ref, rest props and an explicit type', () => { + const ref = createRef() + render( + , + ) + const button = screen.getByRole('button', { name: 'Send' }) + expect(ref.current).toBe(button) + expect(button).toHaveAttribute('type', 'submit') + expect(button).toHaveAttribute('aria-describedby', 'hint') + }) +}) diff --git a/web/src/ui/Button.tsx b/web/src/ui/Button.tsx new file mode 100644 index 0000000..4a58825 --- /dev/null +++ b/web/src/ui/Button.tsx @@ -0,0 +1,16 @@ +import type { ComponentPropsWithRef } from 'react' +import styles from './Button.module.css' + +export type ButtonVariant = 'primary' | 'outline' | 'dark' + +export type ButtonProps = ComponentPropsWithRef<'button'> & { + variant?: ButtonVariant +} + +// Pill button. Primary is grape with --on-accent text; outline and dark are the +// secondary treatments from the handoff. `type` defaults to "button" so a +// Button inside a form never submits by accident. +export function Button({ variant = 'primary', type = 'button', className, ...rest }: ButtonProps) { + const classes = [styles.button, styles[variant], className].filter(Boolean).join(' ') + return + )} + + ) +} diff --git a/web/src/ui/IconButton.module.css b/web/src/ui/IconButton.module.css new file mode 100644 index 0000000..7dd0c66 --- /dev/null +++ b/web/src/ui/IconButton.module.css @@ -0,0 +1,74 @@ +/* The button is the hit box; .face is the visible circle. A 40 px face sits in + a 44 px hit box whose negative margin keeps the 40 px layout footprint. */ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + padding: 0; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: inherit; + cursor: pointer; +} + +.button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.size40 { + width: 44px; + height: 44px; + margin: -2px; +} + +.size62 { + width: 62px; + height: 62px; +} + +.face { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-pill); + transition: transform 100ms var(--ease-out-soft); +} + +.size40 .face { + width: 40px; + height: 40px; +} + +.size62 .face { + width: 62px; + height: 62px; + box-shadow: var(--shadow-md); +} + +.button:active:not(:disabled) .face { + transform: scale(0.97); +} + +.surface { + background: var(--color-surface); + color: var(--color-text); +} + +.accent { + background: var(--color-accent); + color: var(--on-accent); +} + +.teal { + background: var(--color-accent-2-600); + color: var(--on-accent); +} + +@media (prefers-reduced-motion: reduce) { + .face { + transition: none; + } +} diff --git a/web/src/ui/IconButton.test.tsx b/web/src/ui/IconButton.test.tsx new file mode 100644 index 0000000..0341c1a --- /dev/null +++ b/web/src/ui/IconButton.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { hitArea, expectMinHitArea } from '../test/hitArea.ts' +import { IconButton } from './IconButton.tsx' + +describe('IconButton', () => { + it('is named by its label, not its icon', () => { + const onClick = vi.fn() + render( + + + , + ) + const button = screen.getByRole('button', { name: 'Notifications' }) + expect(button).toHaveAttribute('type', 'button') + fireEvent.click(button) + expect(onClick).toHaveBeenCalledOnce() + }) + + it('keeps a 44 px hit area around the 40 px circle', () => { + render() + const button = screen.getByRole('button', { name: 'Back' }) + expectMinHitArea(button) + const face = button.firstElementChild! + expect(hitArea(face)).toEqual({ width: 40, height: 40 }) + }) + + it('is 62 px at the large size', () => { + render() + const button = screen.getByRole('button', { name: 'Try it' }) + expectMinHitArea(button) + expect(hitArea(button)).toEqual({ width: 62, height: 62 }) + }) +}) diff --git a/web/src/ui/IconButton.tsx b/web/src/ui/IconButton.tsx new file mode 100644 index 0000000..1a5ab73 --- /dev/null +++ b/web/src/ui/IconButton.tsx @@ -0,0 +1,33 @@ +import type { ComponentPropsWithRef } from 'react' +import styles from './IconButton.module.css' + +export type IconButtonSize = 40 | 62 +export type IconButtonTone = 'surface' | 'accent' | 'teal' + +export type IconButtonProps = Omit, 'aria-label'> & { + // Accessible name; an icon alone never names a button. + label: string + size?: IconButtonSize + tone?: IconButtonTone +} + +// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS); +// the 62 px size is the round action button with --shadow-md. +export function IconButton({ + label, + size = 40, + tone = 'surface', + type = 'button', + className, + children, + ...rest +}: IconButtonProps) { + const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ') + return ( + + ) +} diff --git a/web/src/ui/ListRow.module.css b/web/src/ui/ListRow.module.css new file mode 100644 index 0000000..f182b5b --- /dev/null +++ b/web/src/ui/ListRow.module.css @@ -0,0 +1,69 @@ +.row { + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--space-3); + width: 100%; + min-height: 44px; + padding: 14px 16px; + color: var(--color-text); + text-align: left; +} + +.interactive { + padding: 0; + border-radius: var(--radius-md); +} + +.interactive:hover { + background: var(--color-neutral-200); +} + +.main { + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--space-3); + flex: 1 1 auto; + min-width: 44px; + min-height: 44px; + padding: 14px 16px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.interactive > .trailing { + padding-right: 16px; +} + +.leading, +.trailing { + display: inline-flex; + align-items: center; + flex: none; +} + +.body { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + +.title { + font-family: var(--font-body); + font-size: 15px; + font-weight: 700; +} + +.meta { + font-family: var(--font-body); + font-size: 12px; + font-weight: 600; + color: var(--color-neutral-700); +} diff --git a/web/src/ui/ListRow.test.tsx b/web/src/ui/ListRow.test.tsx new file mode 100644 index 0000000..f1e9108 --- /dev/null +++ b/web/src/ui/ListRow.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { expectMinHitArea } from '../test/hitArea.ts' +import { ListRow } from './ListRow.tsx' +import { Toggle } from './Toggle.tsx' + +describe('ListRow', () => { + it('renders leading, title, meta and trailing slots without being interactive', () => { + render( + icon} + title="Notifications" + meta="Push and Google Chat" + trailing={chevron} + />, + ) + for (const text of ['icon', 'Notifications', 'Push and Google Chat', 'chevron']) { + expect(screen.getByText(text)).toBeInTheDocument() + } + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('is a single button with a ≥ 44 px hit area when it has onClick', () => { + const onClick = vi.fn() + render() + const row = screen.getByRole('button', { name: '10 new side quests to swipe' }) + expect(row).toHaveAttribute('type', 'button') + expectMinHitArea(row) + fireEvent.click(row) + expect(onClick).toHaveBeenCalledOnce() + }) + + it('keeps a trailing toggle outside the row button so each control fires only its own action', () => { + const onClick = vi.fn() + const onCheckedChange = vi.fn() + render( + } + onClick={onClick} + />, + ) + const row = screen.getByRole('button', { name: /^Dinner reminders/ }) + const toggle = screen.getByRole('switch', { name: 'Dinner reminders' }) + expect(row).not.toContainElement(toggle) + expect(row.querySelector('button')).toBeNull() + + fireEvent.click(toggle) + expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true) + expect(onClick).not.toHaveBeenCalled() + + fireEvent.click(row) + expect(onClick).toHaveBeenCalledOnce() + expect(onCheckedChange).toHaveBeenCalledOnce() + }) +}) diff --git a/web/src/ui/ListRow.tsx b/web/src/ui/ListRow.tsx new file mode 100644 index 0000000..8004bf2 --- /dev/null +++ b/web/src/ui/ListRow.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react' +import styles from './ListRow.module.css' + +export type ListRowProps = { + title: ReactNode + meta?: ReactNode + // Leading slot: icon, photo or avatar. + leading?: ReactNode + // Trailing slot: chevron, toggle, count or action. + trailing?: ReactNode + // When set, the leading slot, title and meta form one button. The trailing + // slot stays a sibling outside it, so a trailing toggle or action is never + // nested in the row's button and never triggers the row's action. + onClick?: () => void + className?: string +} + +export function ListRow({ title, meta, leading, trailing, onClick, className }: ListRowProps) { + const main = ( + <> + {leading && {leading}} + + {title} + {meta && {meta}} + + + ) + const trailingSlot = trailing && {trailing} + if (onClick) { + return ( +
+ + {trailingSlot} +
+ ) + } + return ( +
+ {main} + {trailingSlot} +
+ ) +} diff --git a/web/src/ui/Segmented.module.css b/web/src/ui/Segmented.module.css new file mode 100644 index 0000000..3d94baa --- /dev/null +++ b/web/src/ui/Segmented.module.css @@ -0,0 +1,35 @@ +.track { + display: flex; + gap: 4px; + padding: 4px; + border-radius: var(--radius-pill); + background: var(--color-surface); +} + +.option { + flex: 1 1 0; + min-width: 44px; + min-height: 44px; + padding: 0 var(--space-4); + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-neutral-700); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: background-color 150ms var(--ease-out-soft), box-shadow 150ms var(--ease-out-soft); +} + +.selected { + background: var(--color-bg); + color: var(--color-text); + box-shadow: var(--shadow-sm); +} + +@media (prefers-reduced-motion: reduce) { + .option { + transition: none; + } +} diff --git a/web/src/ui/Segmented.test.tsx b/web/src/ui/Segmented.test.tsx new file mode 100644 index 0000000..c107a1e --- /dev/null +++ b/web/src/ui/Segmented.test.tsx @@ -0,0 +1,91 @@ +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 { Segmented } from './Segmented.tsx' +import styles from './Segmented.module.css' + +const options = [ + { value: 'week', label: 'Week' }, + { value: 'month', label: 'Month' }, + { value: 'year', label: 'Year' }, +] as const + +type Period = (typeof options)[number]['value'] + +function Harness({ initial = 'week' }: { initial?: Period }) { + const [value, setValue] = useState(initial) + return +} + +function radio(name: string) { + return screen.getByRole('radio', { name }) +} + +describe('Segmented', () => { + it('is a named radiogroup of radios with one checked', () => { + render() + expect(screen.getByRole('radiogroup', { name: 'Archive period' })).toBeInTheDocument() + expect(screen.getAllByRole('radio')).toHaveLength(3) + expect(radio('Week')).toHaveAttribute('aria-checked', 'true') + expect(radio('Month')).toHaveAttribute('aria-checked', 'false') + expect(radio('Week')).toHaveClass(styles.selected!) + }) + + it('has a single tab stop on the selected option', () => { + render() + expect(radio('Month')).toHaveAttribute('tabindex', '0') + expect(radio('Week')).toHaveAttribute('tabindex', '-1') + expect(radio('Year')).toHaveAttribute('tabindex', '-1') + }) + + it('moves selection and focus with the arrow keys, wrapping at the ends', () => { + render() + radio('Week').focus() + + fireEvent.keyDown(radio('Week'), { key: 'ArrowRight' }) + expect(radio('Month')).toHaveAttribute('aria-checked', 'true') + expect(radio('Month')).toHaveFocus() + + fireEvent.keyDown(radio('Month'), { key: 'ArrowDown' }) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + + fireEvent.keyDown(radio('Year'), { key: 'ArrowRight' }) + expect(radio('Week')).toHaveAttribute('aria-checked', 'true') + expect(radio('Week')).toHaveFocus() + + fireEvent.keyDown(radio('Week'), { key: 'ArrowLeft' }) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + + fireEvent.keyDown(radio('Year'), { key: 'ArrowUp' }) + expect(radio('Month')).toHaveAttribute('aria-checked', 'true') + expect(radio('Month')).toHaveFocus() + expect(radio('Year')).toHaveAttribute('aria-checked', 'false') + }) + + it('jumps to the first and last option with Home and End', () => { + render() + fireEvent.keyDown(radio('Month'), { key: 'End' }) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + fireEvent.keyDown(radio('Year'), { key: 'Home' }) + expect(radio('Week')).toHaveAttribute('aria-checked', 'true') + }) + + it('selects an option on click', () => { + render() + fireEvent.click(radio('Year')) + expect(radio('Year')).toHaveAttribute('aria-checked', 'true') + expect(radio('Week')).toHaveAttribute('aria-checked', 'false') + }) + + it('gives every option a ≥ 44 px hit area', () => { + render() + for (const option of screen.getAllByRole('radio')) expectMinHitArea(option) + }) + + it('fails fast when the value is not one of the options', () => { + expect(() => + render( {}} />), + ).toThrow('value "decade" is not one of its options') + }) +}) diff --git a/web/src/ui/Segmented.tsx b/web/src/ui/Segmented.tsx new file mode 100644 index 0000000..bd53b27 --- /dev/null +++ b/web/src/ui/Segmented.tsx @@ -0,0 +1,76 @@ +import { useRef, type KeyboardEvent } from 'react' +import styles from './Segmented.module.css' + +export type SegmentedOption = { + value: T + label: string +} + +export type SegmentedProps = { + // Accessible name of the radiogroup. + label: string + options: readonly SegmentedOption[] + value: T + onValueChange: (value: T) => void + className?: string +} + +const step: Record = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } + +// Segmented control with radiogroup semantics: one tab stop (the selected +// option); arrow keys move the selection and focus, wrapping at the ends; +// Home and End jump to the first and last option. +export function Segmented({ label, options, value, onValueChange, className }: SegmentedProps) { + const refs = useRef<(HTMLButtonElement | null)[]>([]) + const selected = options.findIndex((option) => option.value === value) + if (selected === -1) { + throw new Error(`Segmented "${label}": value "${value}" is not one of its options`) + } + + function select(index: number) { + const option = options[index] + if (!option) throw new Error(`Segmented "${label}": no option at index ${index}`) + onValueChange(option.value) + refs.current[index]?.focus() + } + + function onKeyDown(event: KeyboardEvent) { + 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..ce95efb --- /dev/null +++ b/web/src/ui/Slider.test.tsx @@ -0,0 +1,69 @@ +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)', + ) + }) + + it('accepts values at both ends of the range', () => { + render( + <> + + + , + ) + expect(screen.getByRole('slider', { name: 'At min' }).style.getPropertyValue('--slider-fill')).toBe('0%') + expect(screen.getByRole('slider', { name: 'At max' }).style.getPropertyValue('--slider-fill')).toBe('100%') + }) + + it.each([ + [2, 'value (2) must be within [0, 1]'], + [-0.1, 'value (-0.1) must be within [0, 1]'], + [Number.NaN, 'value (NaN) must be within [0, 1]'], + ])('fails fast when the value %s is outside the range', (value, message) => { + expect(() => render()).toThrow(message) + }) +}) diff --git a/web/src/ui/Slider.tsx b/web/src/ui/Slider.tsx new file mode 100644 index 0000000..af430c8 --- /dev/null +++ b/web/src/ui/Slider.tsx @@ -0,0 +1,30 @@ +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})`) + if (!(value >= min && value <= max)) { + throw new Error(`Slider: value (${value}) must be within [${min}, ${max}]`) + } + 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'], }, }) -- 2.55.0 From 732aa92a8c9b4ab9ecf8969d125a71a90657b714 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 18:22:59 -0400 Subject: [PATCH 3/8] Sheet, ErrorBanner, Skeleton and EmptyState (#36) Co-authored-by: dev_agent Co-committed-by: dev_agent --- web/src/test/cssRules.ts | 50 ++++++ web/src/ui/EmptyState.module.css | 30 ++++ web/src/ui/EmptyState.test.tsx | 27 +++ web/src/ui/EmptyState.tsx | 30 ++++ web/src/ui/ErrorBanner.module.css | 16 ++ web/src/ui/ErrorBanner.test.tsx | 32 ++++ web/src/ui/ErrorBanner.tsx | 21 +++ web/src/ui/Sheet.module.css | 57 +++++++ web/src/ui/Sheet.test.tsx | 267 ++++++++++++++++++++++++++++++ web/src/ui/Sheet.tsx | 123 ++++++++++++++ web/src/ui/Skeleton.module.css | 24 +++ web/src/ui/Skeleton.test.tsx | 36 ++++ web/src/ui/Skeleton.tsx | 12 ++ web/src/ui/index.ts | 4 + 14 files changed, 729 insertions(+) create mode 100644 web/src/test/cssRules.ts create mode 100644 web/src/ui/EmptyState.module.css create mode 100644 web/src/ui/EmptyState.test.tsx create mode 100644 web/src/ui/EmptyState.tsx create mode 100644 web/src/ui/ErrorBanner.module.css create mode 100644 web/src/ui/ErrorBanner.test.tsx create mode 100644 web/src/ui/ErrorBanner.tsx create mode 100644 web/src/ui/Sheet.module.css create mode 100644 web/src/ui/Sheet.test.tsx create mode 100644 web/src/ui/Sheet.tsx create mode 100644 web/src/ui/Skeleton.module.css create mode 100644 web/src/ui/Skeleton.test.tsx create mode 100644 web/src/ui/Skeleton.tsx diff --git a/web/src/test/cssRules.ts b/web/src/test/cssRules.ts new file mode 100644 index 0000000..e8413aa --- /dev/null +++ b/web/src/test/cssRules.ts @@ -0,0 +1,50 @@ +// Reads the CSS Modules' rules straight from the stylesheets Vitest injects +// (css: true). jsdom's getComputedStyle cannot answer two things these +// primitives need checked: it resolves var() colours to transparent, and it +// evaluates only "all" and "screen" media queries, so styles inside +// @media (prefers-reduced-motion: reduce) never apply. + +function cssRules(): CSSRule[] { + return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules)) +} + +function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) { + for (const rule of rules) { + if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue + for (const property of Array.from(rule.style)) { + style.setProperty(property, rule.style.getPropertyValue(property)) + } + } +} + +// The declarations the stylesheets give the element, as written. Read the +// shorthands the CSS uses (background, animation): jsdom does not expand +// animation, nor any shorthand holding a var(). Later rules win; specificity +// is not considered. +export function declaredStyle(element: Element): CSSStyleDeclaration { + const style = document.createElement('div').style + collect(element, cssRules(), style) + return style +} + +// Only the declarations @media (prefers-reduced-motion: reduce) gives the element. +export function reducedMotionStyle(element: Element): CSSStyleDeclaration { + const style = document.createElement('div').style + for (const rule of cssRules()) { + if (rule instanceof CSSMediaRule && rule.conditionText.includes('prefers-reduced-motion: reduce')) { + collect(element, Array.from(rule.cssRules), style) + } + } + return style +} + +// The @keyframes the element's animation runs. CSS Modules hash keyframe +// names, so this follows the element's own animation-name. +export function animationKeyframes(element: Element): CSSKeyframesRule { + const [name] = declaredStyle(element).animation.split(' ') + const keyframes = cssRules().find( + (rule): rule is CSSKeyframesRule => rule instanceof CSSKeyframesRule && rule.name === name, + ) + if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`) + return keyframes +} diff --git a/web/src/ui/EmptyState.module.css b/web/src/ui/EmptyState.module.css new file mode 100644 index 0000000..c9ef4f1 --- /dev/null +++ b/web/src/ui/EmptyState.module.css @@ -0,0 +1,30 @@ +.empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + padding: var(--space-8) var(--space-4); + color: var(--color-text); + text-align: center; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 24px; + font-weight: 400; + line-height: 1.15; +} + +.body { + margin: 0; + max-width: 32ch; + font-family: var(--font-body); + font-size: 15px; + line-height: 1.45; + color: var(--color-neutral-700); +} + +.action { + margin-top: var(--space-2); +} diff --git a/web/src/ui/EmptyState.test.tsx b/web/src/ui/EmptyState.test.tsx new file mode 100644 index 0000000..c861752 --- /dev/null +++ b/web/src/ui/EmptyState.test.tsx @@ -0,0 +1,27 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { expectMinHitArea } from '../test/hitArea.ts' +import { EmptyState } from './EmptyState.tsx' + +describe('EmptyState', () => { + it('shows a Bagel title and body without an action by default', () => { + render(Swipe Try it on a Side quest to save it here.) + const title = screen.getByRole('heading', { name: 'Quest log is empty' }) + expect(getComputedStyle(title).fontFamily).toBe('var(--font-heading)') + expect(screen.getByText('Swipe Try it on a Side quest to save it here.')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders the optional primary action and calls its handler', () => { + const onClick = vi.fn() + render( + + Add the dinners you make most. + , + ) + const action = screen.getByRole('button', { name: 'Add a dinner' }) + expectMinHitArea(action) + fireEvent.click(action) + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/ui/EmptyState.tsx b/web/src/ui/EmptyState.tsx new file mode 100644 index 0000000..0c0ecab --- /dev/null +++ b/web/src/ui/EmptyState.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import { Button } from './Button.tsx' +import styles from './EmptyState.module.css' + +export type EmptyStateAction = { + label: string + onClick: () => void +} + +export type EmptyStateProps = { + title: string + children: ReactNode + // Optional primary action, rendered as a primary Button. + action?: EmptyStateAction +} + +// Bagel title, body text and an optional primary action. +export function EmptyState({ title, children, action }: EmptyStateProps) { + return ( +
+

{title}

+

{children}

+ {action && ( + + )} +
+ ) +} diff --git a/web/src/ui/ErrorBanner.module.css b/web/src/ui/ErrorBanner.module.css new file mode 100644 index 0000000..e0ffc8d --- /dev/null +++ b/web/src/ui/ErrorBanner.module.css @@ -0,0 +1,16 @@ +.banner { + display: flex; + align-items: center; + gap: var(--space-3); + background: var(--color-neutral-100); +} + +.message { + flex: 1; + margin: 0; + color: var(--color-accent-700); + font-family: var(--font-body); + font-size: 15px; + font-weight: 600; + line-height: 1.35; +} diff --git a/web/src/ui/ErrorBanner.test.tsx b/web/src/ui/ErrorBanner.test.tsx new file mode 100644 index 0000000..811b5e5 --- /dev/null +++ b/web/src/ui/ErrorBanner.test.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { declaredStyle } from '../test/cssRules.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { ErrorBanner } from './ErrorBanner.tsx' + +describe('ErrorBanner', () => { + it('announces the message and calls onRetry from the Retry button', () => { + const onRetry = vi.fn() + render(Couldn't load the Supply run.) + expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load the Supply run.") + const retry = screen.getByRole('button', { name: 'Retry' }) + expectMinHitArea(retry) + fireEvent.click(retry) + expect(onRetry).toHaveBeenCalledTimes(1) + }) + + it('uses a caller-supplied retry label', () => { + render( + {}} retryLabel="Rebuild week"> + The plan didn't save. + , + ) + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument() + }) + + it('is a neutral-100 card with grape-700 text', () => { + render( {}}>Offline) + expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)') + expect(declaredStyle(screen.getByText('Offline')).color).toBe('var(--color-accent-700)') + }) +}) diff --git a/web/src/ui/ErrorBanner.tsx b/web/src/ui/ErrorBanner.tsx new file mode 100644 index 0000000..1274bf9 --- /dev/null +++ b/web/src/ui/ErrorBanner.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react' +import { Button } from './Button.tsx' +import { Card } from './Card.tsx' +import styles from './ErrorBanner.module.css' + +export type ErrorBannerProps = { + // What went wrong, in the caller's words. + children: ReactNode + onRetry: () => void + retryLabel?: string +} + +// Muted neutral-100 card with grape-700 text and a Retry button. +export function ErrorBanner({ children, onRetry, retryLabel = 'Retry' }: ErrorBannerProps) { + return ( + +

{children}

+ +
+ ) +} diff --git a/web/src/ui/Sheet.module.css b/web/src/ui/Sheet.module.css new file mode 100644 index 0000000..dcb5cd8 --- /dev/null +++ b/web/src/ui/Sheet.module.css @@ -0,0 +1,57 @@ +.root { + position: fixed; + inset: 0; + z-index: 100; +} + +.scrim { + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent); + animation: fade 320ms var(--ease-out-soft); +} + +.panel { + position: absolute; + right: 0; + bottom: 0; + left: 0; + max-height: 90dvh; + overflow-y: auto; + padding: var(--space-6) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom)); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--color-bg); + color: var(--color-text); + box-shadow: var(--shadow-lg); + transform: translateY(0); + animation: rise 320ms var(--ease-out-soft); +} + +.panel:focus { + outline: none; +} + +@keyframes rise { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@keyframes fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .scrim, + .panel { + animation: none; + } +} diff --git a/web/src/ui/Sheet.test.tsx b/web/src/ui/Sheet.test.tsx new file mode 100644 index 0000000..8e8cc27 --- /dev/null +++ b/web/src/ui/Sheet.test.tsx @@ -0,0 +1,267 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { useState } from 'react' +import { describe, expect, it, vi } from 'vitest' +import '../styles/tokens.css' +import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts' +import { Sheet } from './Sheet.tsx' +import styles from './Sheet.module.css' + +function Harness({ withControls = true }: { withControls?: boolean }) { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)}> +

Grouped by dinner

+ {withControls && ( + <> + + + + )} +
+ + ) +} + +function openSheet() { + const opener = screen.getByRole('button', { name: 'Open Supply run' }) + opener.focus() + fireEvent.click(opener) + return { opener, dialog: screen.getByRole('dialog', { name: 'Supply run' }) } +} + +function scrim() { + const element = document.querySelector(`.${styles.scrim}`) + if (!element) throw new Error('scrim not rendered') + return element +} + +describe('Sheet', () => { + it('renders nothing while closed', () => { + render() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('is a labelled modal dialog', () => { + render() + const { dialog } = openSheet() + expect(dialog).toHaveAttribute('aria-modal', 'true') + }) + + it('accepts aria-labelledby as its accessible name', () => { + render( + <> +

Quest log

+ {}}> +

Nothing yet

+
+ , + ) + expect(screen.getByRole('dialog', { name: 'Quest log' })).toBeInTheDocument() + }) + + it('moves focus to the first control and restores it to the opener on close', () => { + render() + const { opener } = openSheet() + expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus() + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(opener).toHaveFocus() + }) + + it('focuses the dialog itself when it has no focusable control', () => { + render() + const { dialog } = openSheet() + expect(dialog).toHaveFocus() + }) + + it('skips controls that cannot take keyboard focus when moving focus in', () => { + render( + {}}> + + + + + + + , + ) + expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus() + }) + + it('focuses the dialog itself when every control is hidden', () => { + render( + {}}> + + , + ) + expect(screen.getByRole('dialog', { name: 'Quest log' })).toHaveFocus() + }) + + it('does not stop the Tab trap on a hidden control', () => { + render( + {}}> + + + + + , + ) + const first = screen.getByRole('button', { name: 'Try it' }) + const last = screen.getByRole('button', { name: 'Pass' }) + + last.focus() + fireEvent.keyDown(last, { key: 'Tab' }) + expect(first).toHaveFocus() + + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }) + expect(last).toHaveFocus() + }) + + it('does not stop the Tab trap on a control disabled by its fieldset', () => { + render( + {}}> +
+ +
+ + +
+ +
+
, + ) + const first = screen.getByRole('button', { name: 'Try it' }) + const last = screen.getByRole('button', { name: 'Pass' }) + expect(first).toHaveFocus() + + last.focus() + fireEvent.keyDown(last, { key: 'Tab' }) + expect(first).toHaveFocus() + + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }) + expect(last).toHaveFocus() + }) + + it('wraps Tab and Shift+Tab inside the sheet', () => { + render() + openSheet() + const first = screen.getByRole('button', { name: 'Try it' }) + const last = screen.getByRole('button', { name: 'Pass' }) + + last.focus() + fireEvent.keyDown(last, { key: 'Tab' }) + expect(first).toHaveFocus() + + fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }) + expect(last).toHaveFocus() + }) + + it('keeps focus on the dialog when Tab has nowhere to go', () => { + render() + const { dialog } = openSheet() + const tab = fireEvent.keyDown(dialog, { key: 'Tab' }) + expect(tab).toBe(false) // default prevented + expect(dialog).toHaveFocus() + }) + + it('closes on Escape', () => { + const onClose = vi.fn() + render( + + + , + ) + fireEvent.keyDown(screen.getByRole('button', { name: 'Try it' }), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('closes on a scrim click but not on a panel click', () => { + const onClose = vi.fn() + render( + +

Grouped by dinner

+
, + ) + fireEvent.click(screen.getByRole('dialog')) + fireEvent.click(screen.getByText('Grouped by dinner')) + expect(onClose).not.toHaveBeenCalled() + fireEvent.click(scrim()) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('makes the background inert while open and restores it on close', () => { + const { container } = render() + openSheet() + const root = screen.getByRole('dialog').parentElement! + expect(root.parentElement).toBe(document.body) + expect(container).toHaveAttribute('inert') + expect(root).not.toHaveAttribute('inert') + + fireEvent.click(scrim()) + expect(container).not.toHaveAttribute('inert') + }) + + it('leaves an already-inert background element inert after close', () => { + const other = document.createElement('div') + other.setAttribute('inert', '') + document.body.append(other) + try { + render() + openSheet() + fireEvent.click(scrim()) + expect(other).toHaveAttribute('inert') + } finally { + other.remove() + } + }) + + it('rises from 100% to 0 over 320 ms with the soft ease-out', () => { + render() + const { dialog } = openSheet() + expect(declaredStyle(dialog).animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)']) + const easing = getComputedStyle(document.documentElement).getPropertyValue('--ease-out-soft') + expect(easing.replace(/\s/g, '')).toBe('cubic-bezier(.2,.8,.2,1)') + const rise = animationKeyframes(dialog) + expect(rise.findRule('from')?.style.transform).toBe('translateY(100%)') + expect(rise.findRule('to')?.style.transform).toBe('translateY(0)') + }) + + it('fades the scrim in to 50% neutral-900', () => { + render() + openSheet() + const style = declaredStyle(scrim()) + expect(style.background).toBe('color-mix(in srgb, var(--color-neutral-900) 50%, transparent)') + expect(style.animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)']) + const fade = animationKeyframes(scrim()) + expect(fade.findRule('from')?.style.opacity).toBe('0') + expect(fade.findRule('to')?.style.opacity).toBe('1') + }) + + it('opens in place with no rise or fade under reduced motion', () => { + render() + const { dialog } = openSheet() + expect(reducedMotionStyle(dialog).animation).toBe('none') + expect(reducedMotionStyle(scrim()).animation).toBe('none') + expect(declaredStyle(dialog).transform).toBe('translateY(0)') + }) +}) diff --git a/web/src/ui/Sheet.tsx b/web/src/ui/Sheet.tsx new file mode 100644 index 0000000..4686f3a --- /dev/null +++ b/web/src/ui/Sheet.tsx @@ -0,0 +1,123 @@ +import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import styles from './Sheet.module.css' + +// The dialog's accessible name: exactly one of a label or a labelling element. +type SheetName = + | { 'aria-label': string; 'aria-labelledby'?: never } + | { 'aria-labelledby': string; 'aria-label'?: never } + +export type SheetProps = SheetName & { + open: boolean + onClose: () => void + children: ReactNode +} + +const focusableSelector = [ + 'a[href]', + 'button', + 'input', + 'select', + 'textarea', + '[tabindex]', +].join(',') + +// Whether an element can take keyboard focus: being disabled (by its own +// attribute or a disabled fieldset), a negative tabIndex, a hidden, aria-hidden +// or inert ancestor (or self), display: none on the way up to the panel, or +// visibility: hidden all rule it out. +function isKeyboardFocusable(element: HTMLElement, panel: HTMLElement): boolean { + if (element.matches(':disabled')) return false + if (element.tabIndex < 0) return false + if (element.closest('[hidden], [aria-hidden="true"], [inert]')) return false + if (getComputedStyle(element).visibility === 'hidden') return false + for (let node: HTMLElement | null = element; node && node !== panel; node = node.parentElement) { + if (getComputedStyle(node).display === 'none') return false + } + return true +} + +function focusables(panel: HTMLElement): HTMLElement[] { + return Array.from(panel.querySelectorAll(focusableSelector)).filter((element) => + isKeyboardFocusable(element, panel), + ) +} + +// Focuses the first control that actually takes focus, or the panel if none does. +function focusFirst(panel: HTMLElement) { + for (const element of focusables(panel)) { + element.focus() + if (document.activeElement === element) return + } + panel.focus() +} + +// Modal bottom sheet. Portalled to so everything else there can be made +// inert while it is open. Focus moves in on open, Tab is trapped inside, and +// focus returns to the opener on close. Escape and a scrim click call onClose. +export function Sheet({ open, onClose, children, ...name }: SheetProps) { + const rootRef = useRef(null) + const panelRef = useRef(null) + + useEffect(() => { + const root = rootRef.current + const panel = panelRef.current + if (!open || !root || !panel) return + + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null + const background = Array.from(document.body.children).filter( + (element) => element !== root && !element.hasAttribute('inert'), + ) + for (const element of background) element.setAttribute('inert', '') + focusFirst(panel) + + return () => { + for (const element of background) element.removeAttribute('inert') + opener?.focus() + } + }, [open]) + + if (!open) return null + + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.stopPropagation() + onClose() + return + } + if (event.key !== 'Tab' || !panelRef.current) return + const items = focusables(panelRef.current) + const first = items[0] + const last = items[items.length - 1] + if (!first || !last) { + event.preventDefault() + return + } + const active = document.activeElement + if (event.shiftKey && (active === first || active === panelRef.current)) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && active === last) { + event.preventDefault() + first.focus() + } + } + + return createPortal( +
+ , + document.body, + ) +} diff --git a/web/src/ui/Skeleton.module.css b/web/src/ui/Skeleton.module.css new file mode 100644 index 0000000..8647c14 --- /dev/null +++ b/web/src/ui/Skeleton.module.css @@ -0,0 +1,24 @@ +.skeleton { + display: block; + width: 100%; + height: 16px; + border-radius: var(--radius-pill); + background: var(--color-surface); + animation: pulse 1.2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + } +} diff --git a/web/src/ui/Skeleton.test.tsx b/web/src/ui/Skeleton.test.tsx new file mode 100644 index 0000000..a588018 --- /dev/null +++ b/web/src/ui/Skeleton.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts' +import { Skeleton } from './Skeleton.tsx' + +function skeleton(container: HTMLElement) { + return container.firstElementChild as HTMLElement +} + +describe('Skeleton', () => { + it('is a hidden pill in --color-surface', () => { + const { container } = render() + const element = skeleton(container) + expect(element).toHaveAttribute('aria-hidden', 'true') + const style = declaredStyle(element) + expect(style.borderRadius).toBe('var(--radius-pill)') + expect(style.background).toBe('var(--color-surface)') + }) + + it('pulses its opacity over 1.2 s', () => { + const { container } = render() + expect(declaredStyle(skeleton(container)).animation.split(' ').slice(1)).toEqual(['1.2s', 'ease-in-out', 'infinite']) + expect(animationKeyframes(skeleton(container)).findRule('50%')?.style.opacity).toBe('0.45') + }) + + it('does not pulse under reduced motion', () => { + const { container } = render() + expect(reducedMotionStyle(skeleton(container)).animation).toBe('none') + }) + + it('merges a caller className and forwards style', () => { + const { container } = render() + expect(skeleton(container)).toHaveClass('line') + expect(skeleton(container).style.width).toBe('60%') + }) +}) diff --git a/web/src/ui/Skeleton.tsx b/web/src/ui/Skeleton.tsx new file mode 100644 index 0000000..75e2458 --- /dev/null +++ b/web/src/ui/Skeleton.tsx @@ -0,0 +1,12 @@ +import type { ComponentPropsWithRef } from 'react' +import styles from './Skeleton.module.css' + +export type SkeletonProps = Omit, 'children'> + +// Loading placeholder: a pill in --color-surface with a 1.2 s opacity pulse. +// Full width and 16 px tall by default; size it with className or style. +// Hidden from assistive technology — announce loading on the container. +export function Skeleton({ className, ...rest }: SkeletonProps) { + const classes = [styles.skeleton, className].filter(Boolean).join(' ') + return