From f395a50d1081f2355fc3e2db7899e7203f54b31f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 03:11:01 -0300 Subject: [PATCH 1/4] chore: start #573 From 6e54a9be9098caa0b991586f09262d3a5b52e067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 03:31:53 -0300 Subject: [PATCH 2/4] docs: concepts viewport, motion, targets and focus, appeal (#573, #574) --- .agents/skills/codeatlas-ui/SKILL.md | 2 +- .changeset/concepts-mcp.md | 5 + AGENTS.md | 2 +- packages/ui/bin/ca-ui-mcp.ts | 46 +++- packages/ui/scripts/ai-entry.ts | 11 + packages/ui/scripts/llms-full.ts | 12 +- packages/ui/tests/ai-entry.test.ts | 1 + packages/ui/tests/llms-full.test.ts | 15 +- packages/ui/tests/mcp-concepts.test.ts | 51 ++++ packages/ui/tests/viewport-model.test.ts | 62 +++++ .../docs/concepts/appeal-and-usability.mdx | 122 ++++++++++ site/src/content/docs/concepts/index.mdx | 31 +++ .../content/docs/concepts/reduced-motion.mdx | 133 +++++++++++ .../docs/concepts/targets-and-focus.mdx | 129 ++++++++++ site/src/content/docs/concepts/viewport.mdx | 220 ++++++++++++++++++ site/src/demos/catalog.ts | 3 + .../demos/concepts/viewport/Hero.meta.json | 13 ++ site/src/demos/concepts/viewport/Hero.svelte | 55 +++++ .../concepts/viewport/Keyboard.meta.json | 21 ++ .../demos/concepts/viewport/Keyboard.svelte | 33 +++ .../concepts/viewport/Simulator.meta.json | 23 ++ .../demos/concepts/viewport/Simulator.svelte | 124 ++++++++++ site/src/demos/concepts/viewport/model.ts | 73 ++++++ 23 files changed, 1169 insertions(+), 18 deletions(-) create mode 100644 .changeset/concepts-mcp.md create mode 100644 packages/ui/tests/mcp-concepts.test.ts create mode 100644 packages/ui/tests/viewport-model.test.ts create mode 100644 site/src/content/docs/concepts/appeal-and-usability.mdx create mode 100644 site/src/content/docs/concepts/reduced-motion.mdx create mode 100644 site/src/content/docs/concepts/targets-and-focus.mdx create mode 100644 site/src/content/docs/concepts/viewport.mdx create mode 100644 site/src/demos/concepts/viewport/Hero.meta.json create mode 100644 site/src/demos/concepts/viewport/Hero.svelte create mode 100644 site/src/demos/concepts/viewport/Keyboard.meta.json create mode 100644 site/src/demos/concepts/viewport/Keyboard.svelte create mode 100644 site/src/demos/concepts/viewport/Simulator.meta.json create mode 100644 site/src/demos/concepts/viewport/Simulator.svelte create mode 100644 site/src/demos/concepts/viewport/model.ts diff --git a/.agents/skills/codeatlas-ui/SKILL.md b/.agents/skills/codeatlas-ui/SKILL.md index ef367778..a85394fc 100644 --- a/.agents/skills/codeatlas-ui/SKILL.md +++ b/.agents/skills/codeatlas-ui/SKILL.md @@ -109,7 +109,7 @@ Icons passed to components (`icon: Settings`) are `@lucide/svelte` components. T Never guess a prop name. This skill and `API.md` track the `main` branch, which can be ahead of the `@codeatlasdev/ui` the project has installed: a prop, snippet or MCP tool listed here may not exist in the installed version. Check the installed version first (`node_modules/@codeatlasdev/ui/package.json`) and prefer what that package answers over this file. Two sources, both generated from the source types: -1. **MCP server (preferred).** `ca-ui-mcp` answers `get_component("Button")` (props, defaults, snippets, `styling`: stable `ca-*` parts, scoped variables, `data-*` attributes), `list_parts("Button")` (just the override surface: call it before writing a `.ca-*` selector), `get_export("setLocale")` (signature and summary of any function, store, constant or type: `caErrorPlugin`, `createErrorHandler`, `nativeFeel`, `primeKeyboard`, `toast`...), `list_components`, `search_tokens("safe-bottom")`, `list_surfaces()` (the `data-style` surface styles and which draw a blur) `get_guide("theming")`, and for the design atlas (269 terms; the 1-5 scales are heuristic, the sources support principles not styles) `search_terms("swiss")`, `get_term("DS001")` and `get_reference("E05")`. Setup: `claude mcp add codeatlas-ui -- npx -y -p @codeatlasdev/ui ca-ui-mcp`. +1. **MCP server (preferred).** `ca-ui-mcp` answers `get_component("Button")` (props, defaults, snippets, `styling`: stable `ca-*` parts, scoped variables, `data-*` attributes), `list_parts("Button")` (just the override surface: call it before writing a `.ca-*` selector), `get_export("setLocale")` (signature and summary of any function, store, constant or type: `caErrorPlugin`, `createErrorHandler`, `nativeFeel`, `primeKeyboard`, `toast`...), `list_components`, `search_tokens("safe-bottom")`, `list_surfaces()` (the `data-style` surface styles and which draw a blur) `get_guide("theming")`, `list_concepts()` and `get_concept("viewport")` (why a screen is built a certain way: each claim labelled with strength, scope and sources), and for the design atlas (269 terms; the 1-5 scales are heuristic, the sources support principles not styles) `search_terms("swiss")`, `get_term("DS001")` and `get_reference("E05")`. Setup: `claude mcp add codeatlas-ui -- npx -y -p @codeatlasdev/ui ca-ui-mcp`. 2. **[API.md](./API.md)**: the same data as one markdown file; its first lines name the version it was generated for. Offline fallback; for the installed version read `node_modules/@codeatlasdev/ui/dist/components.json` instead. Large, search it instead of reading it whole. Components first, then the `# Non-component exports` section grouped by import path. Regenerate with `bun run --cwd packages/ui manifest -- --skill` after changing a component. ## Mobile, theme, SEO diff --git a/.changeset/concepts-mcp.md b/.changeset/concepts-mcp.md new file mode 100644 index 00000000..68eea063 --- /dev/null +++ b/.changeset/concepts-mcp.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": minor +--- + +`ca-ui-mcp` gains `list_concepts` and `get_concept`: the Concepts pages (viewport-complete UX, motion that respects people, targets and focus, appeal is not usability) with every claim's strength label, scope, sources, limits and last-reviewed date. The llms-full concept digest now carries the contract and the prompt only, so the file stays under its size cap. diff --git a/AGENTS.md b/AGENTS.md index 4dfa8a64..001dd5ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,7 +173,7 @@ Every numbered demo (`site/src/demos///.svelte`, id in `si **A11y tests**: test every state (open, error, empty, light, dark) with `await expectNoA11yViolations(container)` from `@codeatlasdev/ui/testing` (needs the optional peer `axe-core`). Inside this repo `packages/ui/tests/a11y.ts` re-exports the same `checkA11y`; never fork the axe options. -13. **Discovering the API**: never guess a prop. Use the `ca-ui-mcp` MCP server (`get_component`, `get_export`, `get_equivalent`, `list_components`, `list_parts`, `get_migration`, `search_tokens`, `list_surfaces`, `get_guide`, and for the design atlas and its verified sources `search_terms`, `get_term`, `get_reference`) or `.agents/skills/codeatlas-ui/API.md`; both cover components and every non-component export (stores, `kit`, `vite`, `astro`, `actions`, `seo`, `forms`, `tokens`: signature + summary) and are generated from the code by `packages/ui/scripts/build-manifest.ts` (runs on `build`). `[ca-ui]` dev warnings (via `devWarn`, `esm-env`) flag an `IconButton` without `aria-label` and a `Dialog` without `title`. +13. **Discovering the API**: never guess a prop. Use the `ca-ui-mcp` MCP server (`get_component`, `get_export`, `get_equivalent`, `list_components`, `list_parts`, `get_migration`, `search_tokens`, `list_surfaces`, `get_guide`, `list_concepts`, `get_concept`, and for the design atlas and its verified sources `search_terms`, `get_term`, `get_reference`) or `.agents/skills/codeatlas-ui/API.md`; both cover components and every non-component export (stores, `kit`, `vite`, `astro`, `actions`, `seo`, `forms`, `tokens`: signature + summary) and are generated from the code by `packages/ui/scripts/build-manifest.ts` (runs on `build`). `[ca-ui]` dev warnings (via `devWarn`, `esm-env`) flag an `IconButton` without `aria-label` and a `Dialog` without `title`. 14. **Agent skills layout** (`npx skills add codeatlasdev/ui`): one folder per skill under `.agents/skills/`, named exactly like the `name:` in its `SKILL.md` (`codeatlas-ui`, `codeatlas-dx`, `codeatlas-writing`). Never put a `SKILL.md` directly in `.agents/skills/` (the CLI then treats the directory as a single skill and lists nothing else), and quote any `description:` that contains `: ` (an unquoted one is a YAML error and the CLI silently skips the skill). `packages/ui/tests/skills.test.ts` enforces this. diff --git a/packages/ui/bin/ca-ui-mcp.ts b/packages/ui/bin/ca-ui-mcp.ts index 70f1bbff..56f405db 100755 --- a/packages/ui/bin/ca-ui-mcp.ts +++ b/packages/ui/bin/ca-ui-mcp.ts @@ -6,7 +6,7 @@ * claude mcp add codeatlas-ui -- npx -y -p @codeatlasdev/ui ca-ui-mcp * * Tools: get_recipe, list_components, get_component, get_export, get_equivalent, list_parts, search_tokens, list_surfaces, get_guide, - * search_terms, get_term, get_reference (the design atlas and its verified sources), search_demos, get_demo, search_ui, list_facets, get_related, report_issue (drafts a sanitized GitHub issue from a library problem; it never posts anything). + * list_concepts, get_concept (the Concepts pages with their claims, limits and sources), search_terms, get_term, get_reference (the design atlas and its verified sources), search_demos, get_demo, search_ui, list_facets, get_related, report_issue (drafts a sanitized GitHub issue from a library problem; it never posts anything). */ import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -478,6 +478,50 @@ server.registerTool( } ); +/** The Concepts docs group: pages whose claims about users carry a strength label, a scope and a review date. */ +const concepts = guides.filter((g) => g.group === "Concepts" && g.slug !== "concepts/index"); +const conceptId = (g: ManifestGuide) => g.slug.replace(/^concepts\//, ""); +const reviewedOf = (g: ManifestGuide) => + g.markdown.match(/Last reviewed (\d{4}-\d{2}-\d{2})\./)?.[1]; + +server.registerTool( + "list_concepts", + { + description: + "List the concept pages (why interfaces are built a certain way): id, title, last-reviewed date and what each covers. Each claim in a concept carries a strength label (established, supported, preliminary, practice, heuristic), its scope and its sources. Read `evidence` first for the rules. Get one with get_concept.", + inputSchema: {}, + }, + () => + text( + concepts + .map( + (g) => + `- ${conceptId(g)}: ${g.title}${reviewedOf(g) ? ` (reviewed ${reviewedOf(g)})` : ""}. ${g.description}` + ) + .join("\n") || "No concepts in this build." + ) +); + +server.registerTool( + "get_concept", + { + description: + "One concept page as markdown with its definition, evidence claims (strength label, sources, scope), the contract, library implementation, anti-patterns, checklist, a prompt for your AI, limits with the last-reviewed date, and references with what each does not support. `id` is from list_concepts (viewport, evidence).", + inputSchema: { id: z.string() }, + }, + ({ id }) => { + const n = squash(id.replace(/^concepts\//, "")); + const hit = + concepts.find((g) => squash(conceptId(g)) === n) ?? + concepts.find((g) => squash(g.title) === n); + if (!hit) + return fail( + `No concept "${id}". Concepts: ${concepts.map(conceptId).join(", ") || "none in this build"}.` + ); + return text(`# ${hit.title}\n\n${hit.description}\n\n${hit.markdown}`); + } +); + server.registerTool( "search_terms", { diff --git a/packages/ui/scripts/ai-entry.ts b/packages/ui/scripts/ai-entry.ts index e8282621..8c800149 100644 --- a/packages/ui/scripts/ai-entry.ts +++ b/packages/ui/scripts/ai-entry.ts @@ -127,6 +127,17 @@ export function buildLlmsIndex(i: { heading: "Setup", links: [page(ENTRY_PAGES.install, "Installation"), page(ENTRY_PAGES.aiAgents, "AI agents")], }, + { + heading: "Concepts", + links: [ + { + title: "Viewport-complete UX", + href: `${SITE}${llmsPagePath("concepts/viewport")}`, + description: + "the first screen, viewport units, safe areas, the keyboard, reflow and the cue that there is more below; every claim carries a strength label, scope and sources, or ask the MCP server (`list_concepts`, `get_concept`)", + }, + ], + }, { heading: "Design atlas", links: [ diff --git a/packages/ui/scripts/llms-full.ts b/packages/ui/scripts/llms-full.ts index 597c1851..c8dfe078 100644 --- a/packages/ui/scripts/llms-full.ts +++ b/packages/ui/scripts/llms-full.ts @@ -60,15 +60,11 @@ export function propsSummary(markdown: string): string { /** * What a concept page contributes to llms-full.txt: its definition (the text before the first `## `) and the - * sections an agent acts on, in page order. The evidence, demos, limits and references stay in the page's own - * file (linked from the section), so a concept costs a few KB and the file does not grow with every page. + * sections an agent acts on, in page order: the contract and the paste-in prompt. The evidence, anti-patterns, + * checklist, demos, limits and references stay in the page's own file (linked from the section), so a concept + * costs about 3 KB and the file stays under its cap as concepts are added. */ -export const CONCEPT_DIGEST_SECTIONS: readonly string[] = [ - "The contract", - "Anti-patterns", - "Checklist", - "Prompt for your AI", -]; +export const CONCEPT_DIGEST_SECTIONS: readonly string[] = ["The contract", "Prompt for your AI"]; /** The digest of a concept page's markdown: the lead and the `CONCEPT_DIGEST_SECTIONS`. */ export function conceptDigest(markdown: string): string { diff --git a/packages/ui/tests/ai-entry.test.ts b/packages/ui/tests/ai-entry.test.ts index 127d7a6c..9b2f39ce 100644 --- a/packages/ui/tests/ai-entry.test.ts +++ b/packages/ui/tests/ai-entry.test.ts @@ -252,6 +252,7 @@ describe("llms.txt links the agent-readable .txt form of every page and recipe ( ...recipes.map((r) => `${SITE}${llmsRecipePath(r.id)}`), `${SITE}/llms-full.txt`, `${SITE}/llms/atlas.txt`, + `${SITE}/llms/concepts/viewport.txt`, ]); for (const m of out.matchAll(/\]\((https:\/\/[^)]*\.txt)\)/g)) { const url = m[1]; diff --git a/packages/ui/tests/llms-full.test.ts b/packages/ui/tests/llms-full.test.ts index 59e3c542..9fe432e5 100644 --- a/packages/ui/tests/llms-full.test.ts +++ b/packages/ui/tests/llms-full.test.ts @@ -65,16 +65,17 @@ describe("a concept page in llms-full.txt", () => { "- [E01] A source.", ].join("\n"); - it("keeps the lead, the contract, the checklist and the prompt, and drops evidence and references", () => { + it("keeps the lead, the contract and the prompt, and drops evidence, checklist and references", () => { const d = conceptDigest(body); - for (const kept of [ - "Lead paragraph", - "## The contract", + for (const kept of ["Lead paragraph", "## The contract", "## Prompt for your AI"]) + expect(d).toContain(kept); + for (const dropped of [ + "## Evidence", "## Checklist", - "## Prompt for your AI", + "## References", + "A long evidence section", + "Reflows at 320 CSS px", ]) - expect(d).toContain(kept); - for (const dropped of ["## Evidence", "## References", "A long evidence section"]) expect(d).not.toContain(dropped); }); diff --git a/packages/ui/tests/mcp-concepts.test.ts b/packages/ui/tests/mcp-concepts.test.ts new file mode 100644 index 00000000..447a76b5 --- /dev/null +++ b/packages/ui/tests/mcp-concepts.test.ts @@ -0,0 +1,51 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +// list_concepts and get_concept over the real stdio server: needs `bun run build` (dist/bin + dist/guides.json). +const bin = join(import.meta.dir, "../dist/bin/ca-ui-mcp.js"); +const built = existsSync(bin) && existsSync(join(import.meta.dir, "../dist/guides.json")); + +describe.skipIf(!built)("ca-ui-mcp concepts", () => { + const client = new Client({ name: "test", version: "0.0.0" }); + beforeAll(async () => { + await client.connect(new StdioClientTransport({ command: "node", args: [bin] })); + }); + afterAll(() => client.close()); + + const call = async (name: string, args: Record = {}) => { + const res = (await client.callTool({ name, arguments: args })) as { + isError?: boolean; + content: { text: string }[]; + }; + return { error: res.isError === true, text: res.content[0]?.text ?? "" }; + }; + + it("lists every concept with its review date", async () => { + const { error, text } = await call("list_concepts"); + expect(error).toBe(false); + expect(text).toContain("- evidence:"); + expect(text).toContain("- viewport:"); + for (const line of text.split("\n")) expect(line).toMatch(/\(reviewed \d{4}-\d{2}-\d{2}\)/); + }); + + it("returns a concept with its claims scoped and its limits dated", async () => { + for (const id of ["viewport", "concepts/viewport", "Viewport-complete UX"]) { + const { error, text } = await call("get_concept", { id }); + expect(error).toBe(false); + expect(text).toContain("# Viewport-complete UX"); + expect(text).toContain("Scope:"); + expect(text).toContain("## Limits"); + expect(text).toContain("Last reviewed "); + expect(text).toContain("Does not support:"); + } + }); + + it("names the known concepts when the id is wrong", async () => { + const { error, text } = await call("get_concept", { id: "nope" }); + expect(error).toBe(true); + expect(text).toContain("viewport"); + }); +}); diff --git a/packages/ui/tests/viewport-model.test.ts b/packages/ui/tests/viewport-model.test.ts new file mode 100644 index 00000000..d4e0f30d --- /dev/null +++ b/packages/ui/tests/viewport-model.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test"; +import { + DEVICE_PRESETS, + type DeviceModel, + hiddenBy, + viewportHeights, +} from "../../../site/src/demos/concepts/viewport/model.ts"; + +// The arithmetic the viewport concept's simulator shows. It states the relations MDN documents (vh equals lvh; +// svh is the small viewport; dvh follows the bars), so a wrong number on the page would be a wrong teaching. +const base: DeviceModel = { + screen: 844, + bars: 110, + safe: { top: 47, bottom: 34 }, + keyboard: 0, + collapsed: false, + keyboardMode: "resizes-content", +}; + +describe("viewport heights", () => { + it("svh is the small viewport, lvh the large one, dvh follows the bars", () => { + const shown = viewportHeights(base); + expect([shown.svh, shown.lvh, shown.dvh]).toEqual([734, 844, 734]); + const collapsed = viewportHeights({ ...base, collapsed: true }); + expect([collapsed.svh, collapsed.lvh, collapsed.dvh]).toEqual([734, 844, 844]); + }); + + it("a 100lvh block hides its bottom under the browser bars until they collapse", () => { + expect(hiddenBy("lvh", base)).toBe(110); + expect(hiddenBy("svh", base)).toBe(0); + expect(hiddenBy("lvh", { ...base, collapsed: true })).toBe(0); + }); + + it("takes the keyboard off the visible height and reports the inset only when the layout does not shrink", () => { + const content = viewportHeights({ ...base, keyboard: 300 }); + expect(content.visible).toBe(434); + expect(content.keyboardInset).toBe(0); + const visual = viewportHeights({ ...base, keyboard: 300, keyboardMode: "resizes-visual" }); + expect(visual.visible).toBe(434); + expect(visual.keyboardInset).toBe(300); + }); + + it("drops the bottom safe area while the keyboard covers it", () => { + expect(viewportHeights(base).usable).toBe(734 - 47 - 34); + expect(viewportHeights({ ...base, keyboard: 300 }).usable).toBe(434 - 47); + }); + + it("never goes negative for impossible inputs", () => { + const h = viewportHeights({ + ...base, + screen: 100, + bars: 500, + keyboard: 900, + safe: { top: 999, bottom: 999 }, + }); + for (const v of Object.values(h)) expect(v).toBeGreaterThanOrEqual(0); + }); + + it("keeps every preset consistent (bars smaller than the screen)", () => { + for (const p of Object.values(DEVICE_PRESETS)) expect(p.bars).toBeLessThan(p.screen); + }); +}); diff --git a/site/src/content/docs/concepts/appeal-and-usability.mdx b/site/src/content/docs/concepts/appeal-and-usability.mdx new file mode 100644 index 00000000..86f4a219 --- /dev/null +++ b/site/src/content/docs/concepts/appeal-and-usability.mdx @@ -0,0 +1,122 @@ +--- +title: Appeal is not usability +description: What visual appeal can and cannot tell you about whether an interface works. The aesthetic-usability effect, the studies on quick judgements, why group preferences do not predict a person, and how to test a style before you commit. +group: Concepts +order: 5 +--- +import { Callout } from "@codeatlasdev/ui"; +import Claim from "~/components/docs/Claim.astro"; +import Limits from "~/components/docs/Limits.astro"; +import Refs from "~/components/docs/Refs.astro"; + +"People prefer clean, minimal interfaces" and "a beautiful interface feels easier to use" are repeated often and carry more certainty than the sources behind them. This page reads what those sources measured, so a choice of style rests on a test with your users and not on a slogan. + +## Definition and vocabulary + +| Term | What it means here | +|---|---| +| Visual appeal | How attractive a person rates what they see. A rating, not a behavior. | +| Usability | Whether people complete tasks, quickly, correctly and with little friction. Measured by tasks, not by taste. | +| Pragmatic and hedonic qualities | Pragmatic: it helps me do the task. Hedonic: it interests me, or it says something about who I am. Related to appeal and to usability, but not the same as either. | +| Aesthetic-usability effect | A tendency to find an appealing interface easier to use than it is. | +| Prototypicality | How much a design looks like the typical member of its category. | +| Preference | A stated choice. Keep it apart from performance. | + +## Evidence + + +Ratings of visual appeal were highly correlated between 50 ms and 500 ms exposures. Appeal ratings form quickly and stay consistent. This does not show that people decide to stay, trust or buy in 50 ms, and it is not behavior on a live site. + + + +Visual complexity and prototypicality both affected aesthetic ratings, within the first 50 ms in the first study and down to 17 ms in the second. Complexity and familiarity of the composition show up early in how a page is rated. They say nothing about whether the page is understood. + + + +Perceived goodness (satisfaction) depended on perceived usability and on hedonic attributes, while perceived beauty largely depended on identification, a hedonic attribute. Beauty and goodness were related but not the same thing. + + + +Users are more tolerant of minor usability problems in an interface they find appealing, and this can mask problems during usability testing. A good-looking prototype that tests well on preference may still have task problems. + + + +The level of colorfulness and visual complexity at which appeal is highest varies between groups. A group average is not a person: it does not support assigning a style to an age, a gender or any other group. + + + +None of these sources shows that a named style (minimal, brutalist, glass, editorial) is preferred by anyone or performs better at anything. They concern complexity, familiarity, usability and appeal in general. Style claims in our atlas rest on that indirect evidence and are labelled so. + + +## The contract + + +**Measure tasks and preference separately.** A vote for the nicer prototype is a preference. Completion, time, errors and comprehension are the usability result. Report both and never let one stand for the other. + + + +**Expect appeal to flatter the test.** When two designs differ in polish, assume the polished one will be forgiven some friction in a short test. Equalize fidelity or look for the friction deliberately. + + + +**Choose a style by task and context, not by who you imagine the user is.** Say what the person is doing, how often, under what pressure and with what repertoire. Never write an audience as an age, a gender or a generation. + + + +**Compare against a plain baseline.** The same content and behavior in a functional version, an expressive version and a hybrid. Differences in outcome then belong to the style and not to the content. + + + +**Do not quote a time as a threshold.** "50 ms" is a presentation time in particular lab studies. It is not how long users take to decide. + + +## How the library implements it + +| Need | In `@codeatlasdev/ui` | +|---|---| +| Try a style on the same content | `defaultStyle` and the `designStyle(...)` recipes change tokens, not components, so a baseline and a variant share all behavior. See [Theming](/docs/getting-started/theming/). | +| A plain baseline | The default `solid` style: opaque paper, no blur, no ornament. | +| Compare at several widths | [PreviewStage](/docs/layout/preview-stage/) renders a component or page at real widths in an isolated theme. | +| Browse styles with their caveats | The [design atlas](/atlas/): each term states its risk, the sources and what they do not support, and labels its scores heuristic. | +| Ask from an agent | `search_terms`, `get_term` and `get_reference` in `ca-ui-mcp`. | + +## Anti-patterns + +| Anti-pattern | Why it fails | Instead | +|---|---|---| +| "Users prefer minimal" with no source | Nothing in these studies says so. | State the task and test it. | +| Picking the style by the audience's age or gender | A group average does not describe a person. | Describe the task, frequency and context. | +| Shipping the prototype people liked most without task data | Appeal can mask friction. | Run the same tasks on both. | +| Comparing a polished design with a rough one | Polish changes tolerance. | Equalize fidelity. | +| Citing "50 ms" as how long users take to decide | It is a presentation time for appeal ratings. | Cite what was measured. | +| Treating a platform guide as proof | It is the owner's guidance, not a study. | Cite it as guidance and test the outcome. | + +## Checklist + +- The decision names the task, the frequency and the context, not a demographic. +- Task completion, time and errors are measured, with preference kept separate. +- The prototypes share content and behavior and differ only in the style under test. +- Fidelity is equal across the options being compared. +- Frequent and occasional users, and people with accessibility needs, are in the sample. +- No number from a lab study is quoted without its conditions. + +## Prompt for your AI + +```text +Help choose a visual style without overclaiming. Treat appeal as a rating, not as usability. Do not say a style is preferred by, or suited to, an age, a gender or a generation; describe the task, how often it happens, the pressure and the person's repertoire. + +Build three prototypes with the same content and behavior: a plain baseline (the default solid style in @codeatlasdev/ui), an expressive variation and a hybrid, using designStyle recipes and --ca-* tokens only. Equalize fidelity. Propose a test that measures completion, time, errors, comprehension and preference separately, with frequent and occasional users and people with accessibility needs. + +Do not quote 50 ms or any lab figure as a threshold; say what was measured. Label any claim about a style as indirect evidence. +``` + + +- E05, E01 and E04 are laboratory studies of ratings. They show that appeal forms fast and that beauty and usability are different judgements; they do not measure behavior on a live site. +- E04 used four skins of one product type. Do not read it as a result for all interfaces. +- E19 is a practitioner synthesis built on one example. It is a caution to test, not an effect size. +- E03 compares groups of raters on screenshots. It is cited to block stereotypes, not to predict taste. +- No source here shows that any named style performs better, so style choices remain a hypothesis for you to test. +- This page covers visual style for screens, not voice or spatial interfaces. + + + diff --git a/site/src/content/docs/concepts/index.mdx b/site/src/content/docs/concepts/index.mdx index 62c8df85..d73fc88a 100644 --- a/site/src/content/docs/concepts/index.mdx +++ b/site/src/content/docs/concepts/index.mdx @@ -10,6 +10,10 @@ The component pages tell you what to call. The concept pages tell you what to bu The five strength labels, the nine rules and how to validate before you commit. Read this first. + The first screen, viewport units, safe areas, the keyboard and the cue that there is more below. + When motion helps, when it hurts, and how to honor the reduced-motion setting without losing meaning. + Target size, focus that is visible and never hidden, and modal dialogs that manage focus. + What visual appeal can and cannot say about whether an interface works, and how to test a style. ## How a concept page is built @@ -35,3 +39,30 @@ Add `site/src/content/docs/concepts/.mdx` with `group: Concepts`. Wrap each Each page is also plain markdown at `/llms/concepts/.txt`, with its claims, scope, limits and references intact. The `ca-ui-mcp` server returns it with `get_guide`. + +## The backlog + +Twenty concepts we plan to write, ordered by how often a coding agent gets them wrong when nothing tells it better. Each is written in full with the template above only after its sources are opened and recorded. Until a concept is written there is no page and no claim. + +| # | Concept | Scope in one line | +|---|---|---| +| 1 | Viewport-complete UX | Written. The first screen, viewport units, safe areas, keyboard, reflow. | +| 2 | Targets and focus | Written. Target size, visible focus, focus not hidden, modal focus. | +| 3 | Motion that respects people | Written. Essential and non-essential motion, the reduced-motion setting. | +| 4 | Appeal is not usability | Written. What appeal ratings do and do not show. | +| 5 | Color is never the only signal | State, error and status carried by more than hue. | +| 6 | Contrast across themes | Text and outline contrast in light, dark and high-contrast, and in every preset. | +| 7 | Forms that finish | Labels, errors, autocomplete, input types and recovery from a mistake. | +| 8 | Empty, loading and error states | What a screen shows before, between and after data. | +| 9 | Progressive disclosure, honestly | What may be hidden and what must stay visible: prices, limits, frequent actions. | +| 10 | Navigation and orientation | Where am I, where can I go, how do I get back, on a phone and a desktop. | +| 11 | Dense data and tables | Scanning, alignment, sorting, row actions and mobile fallbacks. | +| 12 | Text that reads | Measure, line height, scale, truncation and what survives a long translation. | +| 13 | Internationalization in layout | Length growth, right-to-left, numbers, dates and currency. | +| 14 | Overlays and layers | Dialogs, popovers, tooltips and toasts: which one, and how they stack. | +| 15 | Touch, pointer and hover | Designing for coarse pointers without breaking the mouse, and no hover-only actions. | +| 16 | Performance as experience | Loading, layout shift, interaction latency and what the thresholds measure. | +| 17 | Offline and slow networks | What a screen does with no connection, and what it promises. | +| 18 | Trust and confirmation | Destructive actions, undo, confirmation wording and honest defaults. | +| 19 | Notifications and interruption | When to interrupt, how often and how to let people turn it off. | +| 20 | Adaptive and generated interfaces | Auditability, reversal and unwanted surprise when an interface changes itself. | diff --git a/site/src/content/docs/concepts/reduced-motion.mdx b/site/src/content/docs/concepts/reduced-motion.mdx new file mode 100644 index 00000000..30e80ce5 --- /dev/null +++ b/site/src/content/docs/concepts/reduced-motion.mdx @@ -0,0 +1,133 @@ +--- +title: Motion that respects people +description: When motion helps, when it hurts, and how to honor the reduced-motion setting without losing meaning. What the standards ask for, what the evidence does not say, and how MotionStage and the motion tokens implement it. +group: Concepts +order: 3 +--- +import { Callout } from "@codeatlasdev/ui"; +import Claim from "~/components/docs/Claim.astro"; +import Limits from "~/components/docs/Limits.astro"; +import Refs from "~/components/docs/Refs.astro"; + +Motion is a language: a panel that slides in tells you where it came from, a button that presses tells you it heard you. It is also a hazard for some people and a cost for everyone on a slow device. This page separates what the standards require from what is taste, and states what nobody has measured. + +## Definition and vocabulary + +| Term | What it means here | +|---|---| +| Essential motion | Motion without which the content or function is lost (a progress indicator, a drag that is the interaction). | +| Non-essential motion | Decoration or polish: parallax, entrance flourishes, ambient loops. | +| Reduced-motion setting | The operating-system preference that `@media (prefers-reduced-motion: reduce)` reads. It asks you to minimize non-essential motion. It does not ask you to remove all change. | +| Vestibular disorder | A condition of the inner ear. Motion on screen can trigger dizziness, nausea or headaches in some people with it. | +| Final state | What the interface looks like when a motion ends. Under reduced motion we render this directly. | + +## Evidence + + +Non-essential motion triggered by interaction, such as parallax scrolling, can trigger vestibular disorders, so content should let people turn it off or honor their setting. Essential motion is excepted. + + + +`prefers-reduced-motion` detects whether a person asked their device to minimize non-essential motion. It is the supported way to read that choice from CSS. + + + +Changing the pace or direction of scrolling can break people's expectation of control and cause disorientation. This is a separate concern from vestibular triggers: it applies to everyone. + + + +Whether a scroll effect distracts or helps depends on how persistent and responsive it is and how sparingly it is applied. It is not a ban on the pattern. + + + +Nothing cited here measures whether animation raises comprehension, delight or conversion. When we recommend motion for feedback or orientation, that is our design judgement. + + +## The contract + + +**Motion explains or it goes.** Keep an animation when it shows where something came from, what changed or that an action registered. Remove it when it only fills time. + + + +**Reduced motion keeps the meaning and drops the movement.** Under the setting, show the final state at once. Do not hide content, delay it or turn a transition into a blank. A cross-fade of a few tens of milliseconds can still tell people that something changed. + + + +**Never let content wait for an animation.** The heading, the explanation and the main action are in the page from the start; a script that reveals them late fails when it errors, loads slowly or the setting is on. + + + +**Give control to motion that runs on its own.** Anything that moves for more than a few seconds needs a pause control. Ambient loops sit behind content and carry no meaning. + + + +**Leave native scroll alone.** No scroll-jacking for essential content. + + +## How the library implements it + +| Need | In `@codeatlasdev/ui` | +|---|---| +| Read the setting in code | `reducedMotion` store: `reducedMotion.value` (call `reducedMotion.init()` once in the root layout; it returns a cleanup). | +| Read it in CSS | `@media (prefers-reduced-motion: reduce)`: the library's own surface, token and utility styles already include it. | +| A motion theme with no entrance | `motion: { enter: "none" }` sets `--ca-enter-scale: 0`, which `MotionStage` treats like the setting. | +| A frame that plays only in view | [MotionStage](/docs/motion/motion-stage/): starts when half is in view, stops when it leaves, hands control to the user on touch, always offers Pause, and renders the final state under reduced motion (`m.reduced`). | +| Motion vocabulary and exact timing | The [Motion](/docs/motion/) section: each demo states its duration, easing and delay, so no agent guesses them. | +| Bottom sheets | `Sheet` disables swipe-to-dismiss when the setting is on. | + +```svelte + + +
Invoice paid
+``` + +```css +.card { transition: transform var(--ca-duration-base) var(--ca-ease-standard); } +@media (prefers-reduced-motion: reduce) { + .card { transition: none; } +} +``` + +## Anti-patterns + +| Anti-pattern | Why it fails | Instead | +|---|---|---| +| Hiding content until a reveal animation runs | Blank page if the script fails or the setting is on. | Render the content visible; animate only the extra. | +| `animation: none` and `opacity: 0` left in place | Removes the movement and the content together. | Set the final state, not the start state. | +| Parallax and scroll-linked movement as the default | A known vestibular trigger. | Offer it only when the setting is off, or drop it. | +| Auto-playing loop with no pause | Takes control away from the person. | A Pause control, or no loop. | +| Reading the setting once at load | The person can change it while the page is open. | `reducedMotion.value` is reactive; the media query updates live. | +| Treating the setting as a switch for all change | People still need to see that state changed. | Keep short fades and color changes; drop travel and scale. | +| Scroll-jacking to stage a story | Disorients, and breaks keyboard and assistive technology. | Native scroll with in-view effects that respect the setting. | + +## Checklist + +- With the setting on, no element travels, scales or loops, and every piece of content is visible. +- Nothing essential is hidden behind a script-driven reveal. +- Anything that keeps moving on its own can be paused. +- The setting is read live, not only at load. +- Scroll behaves natively; no hijacked wheel or touch. +- State changes are still visible without motion (color, icon, text). + +## Prompt for your AI + +```text +Add motion that explains and respects the reduced-motion setting. Keep an animation only when it shows origin, change or acknowledgement. Render content in its final, visible state first; animate only the extra. Under prefers-reduced-motion: reduce show the final state at once, with no travel, scale or loop; keep short fades or color changes so state changes stay visible. + +In @codeatlasdev/ui read reducedMotion.value (call reducedMotion.init() once in the root layout), wrap demos and ambient effects in MotionStage, and use the --ca-duration-* and --ca-ease-* tokens. Give anything that moves longer than a few seconds a Pause control. Never reveal the heading, the explanation or the main action with a script, and never alter native scrolling. + +Do not claim that animation improves comprehension or conversion; none of the cited sources measures that. +``` + + +- Criterion 2.3.3 is Level AAA, above the level most programs target. Honoring the setting is still our baseline, but it is not what an AA audit checks. +- No prevalence figure for vestibular triggers is cited; the standard gives the rationale, not a rate. +- The sources are standards and practitioner articles. None measures whether motion helps or harms comprehension, delight or conversion, so the "motion explains" rules are our judgement. +- "Essential" is a design decision. Two teams can draw the line in different places. +- The Understanding documents change over time; the review date is the day we read them. + + + diff --git a/site/src/content/docs/concepts/targets-and-focus.mdx b/site/src/content/docs/concepts/targets-and-focus.mdx new file mode 100644 index 00000000..05aa7800 --- /dev/null +++ b/site/src/content/docs/concepts/targets-and-focus.mdx @@ -0,0 +1,129 @@ +--- +title: Targets and focus +description: How big a control must be, how to keep keyboard focus visible and never hidden by sticky UI, and how modal dialogs manage focus. The WCAG minimums, the larger sizes we choose, and how the library applies them. +group: Concepts +order: 4 +--- +import { Callout } from "@codeatlasdev/ui"; +import Claim from "~/components/docs/Claim.astro"; +import Limits from "~/components/docs/Limits.astro"; +import Refs from "~/components/docs/Refs.astro"; + +A control people cannot hit, a focus ring they cannot see, and a dialog that lets focus escape are three of the most common ways an interface stops working for someone. The standards say what is required. This page says what they require, what they do not, and where our defaults go further. + +## Definition and vocabulary + +| Term | What it means here | +|---|---| +| Target | The area that responds to a pointer: the whole clickable region, which may be larger than what is drawn. | +| CSS pixel | The unit of the criteria. It is not a physical pixel and not a device model. | +| Minimum level | WCAG's AA threshold. A floor for conformance, not a design target. | +| Focus indicator | The visible mark on the control that has keyboard focus. | +| Obscured focus | Focus on a control that author content, such as a sticky bar, covers. | +| Modal dialog | A window that makes the page behind it unavailable until it closes. | + +## Evidence + + +The target for pointer inputs is at least 24 by 24 CSS pixels, unless an exception applies. The criterion does not require 44 pixels. 44 is a larger size some platforms recommend, and it is the size our touch default uses. + + + +When a control receives keyboard focus, it must not be entirely hidden by author-created content. A sticky header or an action bar that covers the focused field breaks it. + + + +Visual cues needed to identify a control or its state must reach 3:1 against the background. A hairline border that identifies a field is a cue; a decorative one is not. Thin lines can lose perceived contrast to anti-aliasing, so measure the ones that carry meaning. + + + +Text can be resized up to 200 percent without assistive technology and without loss of content or functionality. A control with a fixed pixel height that clips its label fails when the text grows. + + + +In a modal dialog focus moves in on open, Tab and Shift+Tab stay inside, and Escape closes it. + + +## The contract + + +**Draw small, hit big.** A control may look as small as the design wants, as long as its hit area reaches the size for the input. On touch, aim for 44 CSS px; treat 24 as the floor you must not go below. + + + +**Make spacing count.** Two small targets with enough room between them can meet the minimum where one cramped pair cannot. Check the neighbors, not only the control. + + + +**Pad for what you pin.** Any sticky or fixed bar declares its height so scrolling and focus leave room for it. Focus must never land under it. + + + +**A visible, high-contrast ring is not decoration.** Show focus for keyboard users every time, in a color that passes 3:1 on every surface the control can sit on. + + + +**Use the platform dialog.** A modal either uses the native dialog or a component that traps focus, and closes on Escape. Write no hand-rolled version. + + +## How the library implements it + +| Need | In `@codeatlasdev/ui` | +|---|---| +| 44px touch size | `--ca-touch-target` (44px). Under `(pointer: coarse)` control heights grow to it, and small controls (switch, checkbox, radio, icon buttons, pagers, chips) get an invisible 44 by 44 hit area centred on them. | +| Do not write an override | The coarse-pointer rules are global; an app does not add its own. | +| Focus ring | `:focus-visible` with `--ca-focus-ring` and `--ca-focus-ring-offset`; in `nativeFeel()` shells it shows only while the person navigates by keyboard (`html[data-kbd]`). | +| Focus never under sticky UI | `scroll-padding-block` on `:root` reserves `--ca-sticky-top` (default the header height) plus the safe area at the top and `--ca-sticky-bottom` plus the safe area at the bottom. An app with taller chrome sets those two variables. | +| Modal dialogs | Native dialog overlays portaled to the body, with the page behind made inert; focus moves in, Tab stays inside and Escape closes. See [Dialog](/docs/feedback/dialog/). | +| Keyboard with a pinned bar | [StickyActions](/docs/layout/sticky-actions/) and [MobilePane](/docs/layout/mobile-pane/) add safe-area padding and keyboard awareness. | +| Contrast of outlines | `tests/contrast.test.ts` measures 3:1 for outlines in every preset. | + +```css +/* An app whose sticky header is taller than the default tells the page so. */ +:root { + --ca-sticky-top: 72px; + --ca-sticky-bottom: 64px; +} +``` + +## Anti-patterns + +| Anti-pattern | Why it fails | Instead | +|---|---|---| +| An 18px icon button with no extra hit area | Below the minimum, hard to hit. | Keep the look, enlarge the hit area. | +| `outline: none` with no replacement | Keyboard users lose their place. | `:focus-visible` with the ring token. | +| A sticky footer that covers the focused field | Focus is entirely hidden. | Reserve its height in `--ca-sticky-bottom`. | +| A 1px pale border as the only sign of an input | A cue that can fall under 3:1. | A border or fill that measures 3:1 against the background. | +| `height: 36px` on a button whose label can wrap | Clips at 200% text. | `min-height`, padding in `rem`. | +| A custom modal with no focus trap | Focus escapes to the page behind. | The library's `Dialog`, `Sheet` or `MobilePane`. | +| Two tiny targets side by side | Mis-taps, and may fail the spacing exception. | Add space or enlarge one. | + +## Checklist + +- Every pointer target is at least 24 by 24 CSS px, or has an exception you can name. +- On touch, primary controls reach 44 px. +- Focus is visible on every control, in light, dark and high-contrast. +- Tab through the page: focus never ends up entirely under a sticky bar. +- Controls that identify a field or state measure 3:1 against their background. +- At 200% text no label is clipped. +- Open each dialog: focus moves in, Tab stays inside and Escape closes. + +## Prompt for your AI + +```text +Make every control reachable and visible. Pointer targets are at least 24 by 24 CSS px (WCAG 2.5.8) and primary touch controls 44 px; draw small controls small but give them a 44 px hit area. Use @codeatlasdev/ui controls: the coarse-pointer sizing is global, so write no override and no cursor: pointer. + +Never remove the focus outline without replacing it; use :focus-visible with --ca-focus-ring. If a header or bar is sticky or fixed, set --ca-sticky-top or --ca-sticky-bottom so scroll-padding keeps focus clear of it. Any border or fill that identifies a control or state must reach 3:1 contrast. + +Use Dialog, Sheet or MobilePane for modals so focus moves in, stays inside and Escape closes. Use min-height and rem padding on controls so text at 200% does not clip. Treat 44 px as our default and 24 px as the standard's minimum, and do not say the standard requires 44. +``` + + +- The sources are standards and the ARIA Authoring Practices Guide: they say what must hold, not what people prefer or what performs better. +- 44 px is a platform-style recommendation and our default; WCAG 2.2 AA requires 24 px with exceptions. +- Level AA forbids focus being *entirely* hidden. A partly hidden ring passes the minimum and is still a bad experience. +- The criteria cannot be checked by a unit test alone: they need a keyboard pass and a device pass. +- The Understanding documents change over time; the review date is the day we read them. + + + diff --git a/site/src/content/docs/concepts/viewport.mdx b/site/src/content/docs/concepts/viewport.mdx new file mode 100644 index 00000000..243b52e4 --- /dev/null +++ b/site/src/content/docs/concepts/viewport.mdx @@ -0,0 +1,220 @@ +--- +title: Viewport-complete UX +description: Make the first screen say what the product is, who it is for and what to do next, without trapping people in a fixed height. Viewport units, safe areas, the keyboard, reflow and the cue that tells people there is more below. +group: Concepts +order: 2 +--- +import { Callout } from "@codeatlasdev/ui"; +import Claim from "~/components/docs/Claim.astro"; +import Demo from "~/components/docs/Demo.astro"; +import Limits from "~/components/docs/Limits.astro"; +import Refs from "~/components/docs/Refs.astro"; +import Simulator from "~/demos/concepts/viewport/Simulator.svelte"; +import SimulatorSrc from "~/demos/concepts/viewport/Simulator.svelte?raw"; +import Hero from "~/demos/concepts/viewport/Hero.svelte"; +import HeroSrc from "~/demos/concepts/viewport/Hero.svelte?raw"; +import Keyboard from "~/demos/concepts/viewport/Keyboard.svelte"; +import KeyboardSrc from "~/demos/concepts/viewport/Keyboard.svelte?raw"; + +A full-height hero solves a geometry problem: the block uses the visible space. A good first screen solves a communication problem: a visitor can tell what this is, whether it is for them and what to do next. `100dvh` is a way to size a box. It says nothing about whether the box communicates, and it does not mean there is nothing below. + +This page is about the second problem, and about doing it without breaking the first: people who zoom, rotate, open the keyboard or read in another language still need the whole page. + +## Definition and vocabulary + +**Viewport-complete UX** is our working name for one idea: arrange the first visible region so it carries purpose, relevance, trust and the next action, and keep the rest of the page reachable. It is a label for a combination of established principles. It is not a method with validation of its own, and nothing on this page tests it as one. + +| Term | What it means here | +|---|---| +| Above the fold | Content visible before scrolling. Its size changes with the device, the window and the browser bars. | +| Layout viewport | The area the page lays out in. | +| Visual viewport | The part a person can actually see now. Pinch zoom and the keyboard can shrink it without shrinking the layout viewport. | +| `vh`, `svh`, `lvh`, `dvh` | Viewport heights: `lvh` is the large viewport (browser bars collapsed) and `vh` equals it; `svh` is the small one (bars shown); `dvh` follows the bars as they move. | +| Safe area | The region clear of notches, rounded corners and gesture bars, read with `env(safe-area-inset-*)`. | +| False floor | A page that looks finished while more content exists below. Also called the illusion of completeness. | +| Progressive disclosure | Showing the essential first and making the rest discoverable on demand. | + +Four first-view outcomes are worth telling apart, because they describe different screens: + +1. **Complete:** the whole task fits, legible and operable (a login, a confirmation, a kiosk step). +2. **Understand:** the meaning and the main action fit; supporting material follows below. +3. **Intent:** on a very small screen, at least the purpose and the way forward are visible; the full explanation needs scrolling. +4. **Flow:** in an operational app, the active task and its controls come first, not the pitch. + +When goals collide, we order them: understanding, then operability, then accessibility, then discovering the next step, then visual balance, and last the absence of scrolling. + +## Evidence + + +In that study people spent about 57% of their page-viewing time above the fold and 74% in the first two screenfuls. It supports putting the value proposition and the main action early. It does not show that a page should be one screen, and the percentages are not a benchmark for another audience, device or year. + + + +Six of eight users did not realize a page could scroll. It illustrates how a layout can read as complete, and it is not a rate that applies to other sites. + + + +Visual appeal ratings correlate between 50 ms and 500 ms exposures, and visual complexity and prototypicality affect aesthetic ratings that early. Organization and the priority of elements matter at a glance. Nothing here shows that anyone understands a value proposition in that time. + + + +Generous empty space and enlarged elements can spread content across long pages and make it harder to consume. That is a reason to use space to group and separate, and to measure comprehension instead of assuming that more space reads as more premium. + + + +Deferring advanced or rarely needed features makes an application easier to learn and less error-prone. The essential stays visible; the rest must be named, discoverable and reachable by keyboard and touch. + + + +Changing the pace or direction of scrolling can break expectations of control and cause disorientation. Native scrolling is the default to keep. + + + +`vh` is equivalent to `lvh`, so a `100vh` block can sit partly under the browser's own interface while it is expanded. `svh` is the height with the bars shown and `dvh` follows them. + + + +The visual viewport can differ from the layout viewport (zoom, keyboard). `env(safe-area-inset-*)` is zero unless the page opts into edge-to-edge drawing with `viewport-fit=cover`. Taking control of the keyboard with the VirtualKeyboard API is not available in every widely used browser. + + + +Content must reflow to a width equivalent to 320 CSS pixels without two-dimensional scrolling, text must resize to 200% without loss, the page must tolerate user-set text spacing, and it must not lock a single orientation unless one is essential. A layout fixed to one height cannot satisfy these. + + + +Meaningful visual cues need 3:1 contrast (thin hairlines can lose contrast to anti-aliasing), a focused control must not be entirely hidden by author content such as a sticky bar, pointer targets are at least 24 by 24 CSS px with exceptions, and non-essential motion should respect the reduced-motion setting. + + + +Good Core Web Vitals thresholds are 2.5 s for Largest Contentful Paint, 200 ms for Interaction to Next Paint and 0.1 for Cumulative Layout Shift. + + +## The contract + +The principles below are ours. Each follows from the evidence above or from a standard; none is a finding. + + +**The first screen answers four questions:** what is this, who or which situation is it for, what is the main benefit, and what do I do now. If a person cannot answer them from what you show first, add words before you add motion. + + + +**A full-height block is a floor, never a ceiling.** Use `min-height`, never `height`, on anything that holds content. If the content grows because of zoom, a longer translation or a larger font, the block grows and the page scrolls. + + + +**Start from the small viewport.** On phones use `min-height: var(--ca-vh-min)` (the small viewport, so a hero never hides its action behind browser UI) and keep `--ca-vh` for views that must follow the bars. Never write a literal `100vh` or `100dvh`. + + + +**Never fake a no-scroll page.** `overflow: hidden` on the document, a script that measures `innerHeight` on every resize, or absolutely positioned regions that pretend nothing is below all trade accessibility for a look. + + + +**Show that there is more.** If important content continues below, let a visible cue say so (a peeking section, a link such as "How it works") and do not rely on an animated arrow alone. A hero that fills exactly the viewport leaves no room for a peek: choose a shorter hero or an explicit link and accept the trade-off. + + + +**Zero scroll is legitimate in short, bounded steps** (install, confirm, sign in, choose a mode, a kiosk) when every option is visible or explicitly revealable, keyboard and zoom work, and errors and help can expand. It is a property of those screens, not a goal for every page. + + + +**Respond to the container, not the device.** Use media queries for the page's macro structure and container queries for reusable modules, so the same preview works in a sidebar, a modal or a split pane. Do not branch on `isMobile` or a phone model. + + +## How the library implements it + +| Need | In `@codeatlasdev/ui` | +|---|---| +| Heights that survive browser bars | `--ca-vh` (`100vh`, upgraded to `100dvh`) and `--ca-vh-min` (`100svh`) in `tokens.css`. | +| Safe areas | `--ca-safe-top`, `--ca-safe-right`, `--ca-safe-bottom`, `--ca-safe-left`, from `env(safe-area-inset-*, 0px)`. They are non-zero only with `viewport-fit=cover`; see [Mobile](/docs/getting-started/mobile/). | +| The keyboard | `viewport.keyboardInset` and `--ca-kb-inset`, computed from `visualViewport` against the document height (never from `window.innerHeight`). The `keyboardAware` action pads the bottom and scrolls the focused field into view. | +| A bar that stays reachable | [StickyActions](/docs/layout/sticky-actions/): sticks to the bottom of the scroll container with safe-area padding and keyboard awareness, and becomes an inline row from 900px. | +| A full-screen drill-down | [MobilePane](/docs/layout/mobile-pane/): own scroll, safe areas on every side, keyboard awareness, focus in and back out. | +| Page structure | [PageShell](/docs/layout/page-shell/) never emits `
`; `AppShellLayout` provides the one `
`. | +| Container-driven modules | Container queries, as in the components that size to their parent instead of the viewport. | +| Target size | The 44px touch target is global under `(pointer: coarse)`, above the 24px WCAG minimum; do not write an override. | +| Viewport meta | `width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content`. Never `maximum-scale` or `user-scalable=no`. | + + + + + + + +```css +/* A hero that can grow. min-height is the floor; the small viewport keeps the action clear of the browser UI. */ +.hero { + min-height: var(--ca-vh-min); + padding-block: calc(var(--ca-space-8) + var(--ca-safe-top)) var(--ca-space-8); + display: grid; + align-content: center; +} +/* Fluid type with a rem component, so Resize Text still works. Not vw alone. */ +.hero h1 { font-size: clamp(2rem, 1.2rem + 3vw, 3.5rem); max-width: 22ch; } +/* A reusable preview that reacts to the space it is given, not to the device. */ +.preview-host { container-type: inline-size; } +@container (min-width: 36rem) { .preview { grid-template-columns: 1fr 1fr; } } +``` + +## Anti-patterns + +| Anti-pattern | Why it fails | Instead | +|---|---|---| +| `html, body { overflow: hidden }` on a marketing page | Blocks content, zoom and recovery. | Native scroll, `min-height`, a clear priority of content. | +| `height: 100dvh` around a lot of content | Clips when the content grows. | `min-height`, natural flow. | +| A hero that looks like a footer | A false floor: people stop. | A real peek, a continuation link, a clear top navigation. | +| 11px text on mobile so it fits | Hurts reading and fails resize. | Cut the copy, drop ornament, allow scrolling. | +| Headline and action revealed only after an animation | Delays the meaning and breaks reduced motion. | A readable initial state; animation is an extra. | +| A giant product preview on a phone | Takes the space of the message and the action. | A smaller thumbnail, a poster, or content after the action. | +| Everything inside a carousel | Discovery and keyboard access often fail. | Explicit sections or accessible controls. | +| Essential content only in a modal | Hides context and adds friction. | A section in the document, then progressive disclosure. | +| A sticky bar nobody measured | Covers content and the focused control. | Measure its height, add `scroll-padding`, keep safe-area padding, test on a device. | +| `font-size` in `vw` only | Resize Text can fail and the size is hard to predict. | `clamp()` with a `rem` part. | +| A design that works only on the designer's phone | Fails on other devices, at low height and under zoom. | A test matrix by condition, not by model. | + +## Checklist + +- The heading, the explanation and the main action are in the DOM and do not wait for an animation script. +- No critical content is cut by a fixed height or `overflow: hidden`. +- At 200% text, content and functions remain. +- At a width equivalent to 320 CSS px there is no loss and no horizontal scroll (except content that needs two dimensions). +- Pointer targets are at least 24 by 24 CSS px (WCAG 2.5.8), with the larger touch target where the library supplies it. +- Keyboard focus is visible and never entirely hidden by a sticky bar. +- Mobile menus and modals manage focus; Escape closes a dialog and focus returns. +- The page works in portrait and landscape. +- With the keyboard open, a form can still be finished, including a validation error. +- Safe areas never cover an action. +- With reduced motion on, nothing essential is lost. +- No scroll-hijacking is needed to reach essential content. +- Below the first screen there is a visible cue that more exists. + +Test conditions to cover, as samples and not a distribution of devices: 320 by 640, 390 by 844, 360 by 568, 844 by 390, 768 by 1024, a 500 to 700 px split window, 1024 by 768 and 1440 by 900, at 200% and 400% zoom, with larger text and a longer translation, with the keyboard and a validation error, on a real device with a notch and a slow network. + +## Prompt for your AI + +```text +Design the first screen with the viewport-complete approach (our working name for a combination of known principles, not a validated method): +make the product, the audience, the main benefit and the next action understandable from the first visible region, without sacrificing legibility, discovery, accessibility or essential content to force a full-height layout. + +Use min-height, never a fixed height, on anything that holds content. In @codeatlasdev/ui use min-height: var(--ca-vh-min) for a hero and var(--ca-vh) only for views that must follow the browser bars; never write 100vh or 100dvh. Pad fixed UI with var(--ca-safe-*). Wrap forms in use:keyboardAware and use StickyActions or MobilePane instead of hand-built fixed bars. Never put overflow: hidden on the document and never measure window.innerHeight on resize to fake a no-scroll page. + +Let content scroll whenever there is zoom, larger text, a long translation, landscape or an open keyboard. Below the first screen show a visible cue that there is more (a peeking section or a link), not an animation alone. Use container queries for reusable modules and media queries for page structure. + +Meet WCAG 2.2 AA: reflow at 320 CSS px, 200% text, text spacing, visible focus not hidden by sticky UI, 24 px targets, contrast for borders that identify controls, reduced motion. Do not claim that removing scroll raises conversion or that any layout is proven better; validate with comprehension, first-click, task success, errors and Core Web Vitals on real devices. +``` + + + This page is plain markdown at `/llms/concepts/viewport.txt`. The `ca-ui-mcp` server returns it with `get_concept("viewport")`, and `list_concepts` lists every concept with its last-reviewed date. + + + +- The sources support the principles behind this page. They do not validate "viewport-complete UX" as a method, and none of them measures whether the approach raises comprehension or conversion. +- NN/g figures come from single studies on their own pages and samples: the percentages and the six-of-eight count are illustrations, not rates for your audience. +- The 50 ms and 17 ms results are ratings of visual appeal in a lab. They say nothing about understanding a message that quickly. +- Browser behavior moves: viewport units, keyboard resize modes, `viewport-segment-*` and the VirtualKeyboard API vary by browser and version. Check compatibility data and test real devices. +- The simulator is an illustrative model with round numbers, not a browser. +- The test matrix lists engineering samples, not the distribution of devices in use. +- A checklist here does not replace a WCAG audit. + + + diff --git a/site/src/demos/catalog.ts b/site/src/demos/catalog.ts index 8da907c3..378134bc 100644 --- a/site/src/demos/catalog.ts +++ b/site/src/demos/catalog.ts @@ -388,6 +388,9 @@ export const CATALOG: readonly DemoEntry[] = [ { id: "990", path: "inputs/color-field/Brand" }, { id: "991", path: "inputs/color-field/TextContrast" }, { id: "992", path: "inputs/slider/Field" }, + { id: "993", path: "concepts/viewport/Simulator" }, + { id: "994", path: "concepts/viewport/Hero" }, + { id: "995", path: "concepts/viewport/Keyboard" }, ]; /** diff --git a/site/src/demos/concepts/viewport/Hero.meta.json b/site/src/demos/concepts/viewport/Hero.meta.json new file mode 100644 index 00000000..9da1f3e6 --- /dev/null +++ b/site/src/demos/concepts/viewport/Hero.meta.json @@ -0,0 +1,13 @@ +{ + "summary": "A phone-sized scrollable frame holding a hero (eyebrow, heading, lead, filled Button) and a second section, with a switch for 200% text and a long translation and a switch for a continuation cue.", + "use": "Showing that a hero is a floor (min-height) so larger text grows it instead of clipping, and that without a visible cue the next section can read as the end of the page.", + "spec": [ + "`` swaps the title to the h2 token and the lead to a longer string", + "`` toggles a `.is-peek` class on the second section", + "The frame is `inline-size: min(100%, 22rem)`, `block-size: 26rem`, `overflow-y: auto`, `tabindex=\"0\"`, `role=\"region\"` with an `aria-label`", + "`.hero` uses `min-block-size`, never `height`; `` is the primary action", + "The second section is `

How it works

` with one paragraph, separated by a `border-block-start`" + ], + "tags": ["viewport", "hero", "min-height", "false-floor", "scroll-cue", "reflow", "concept"], + "shape": "tall" +} diff --git a/site/src/demos/concepts/viewport/Hero.svelte b/site/src/demos/concepts/viewport/Hero.svelte new file mode 100644 index 00000000..8dcc3440 --- /dev/null +++ b/site/src/demos/concepts/viewport/Hero.svelte @@ -0,0 +1,55 @@ + + +
+
+ + +
+ +
+
+

Inventory and point of sale

+

One system for the counter, the stock room and the kitchen

+

+ {large + ? 'Sell at the counter, count stock in the back and send orders to the kitchen from the same screen, with the same people and the same numbers, even when the connection drops for a while.' + : 'Sell, count stock and send orders from one screen.'} +

+ +
+
+

How it works

+

Three modules, one data model. Content below the first screen is reachable by scrolling and by the link above.

+
+
+
+ + diff --git a/site/src/demos/concepts/viewport/Keyboard.meta.json b/site/src/demos/concepts/viewport/Keyboard.meta.json new file mode 100644 index 00000000..38e09b8c --- /dev/null +++ b/site/src/demos/concepts/viewport/Keyboard.meta.json @@ -0,0 +1,21 @@ +{ + "summary": "A scrollable account form with three TextFields (work email, company, role) and a StickyActions bar with a stretched Continue button, wrapped in the keyboardAware action.", + "use": "A form that stays operable with the on-screen keyboard open: the page scrolls, the focused field comes into view and the action bar rides above the keyboard and the safe area.", + "spec": [ + "``, then `Company` and `Role` fields with matching `autocomplete`", + "A filler `div` of at least `8rem` so the form scrolls", + "`` holds ` + + + + diff --git a/site/src/demos/concepts/viewport/Simulator.meta.json b/site/src/demos/concepts/viewport/Simulator.meta.json new file mode 100644 index 00000000..6237c3dc --- /dev/null +++ b/site/src/demos/concepts/viewport/Simulator.meta.json @@ -0,0 +1,23 @@ +{ + "summary": "An interactive phone drawing with a device-shape selector, switches for collapsed browser bars and edge-to-edge drawing, a keyboard height slider and a keyboard resize mode, plus a table of what 100lvh, 100svh and 100dvh resolve to and how much of each is hidden.", + "use": "Teaching why a full-height block must be a floor (min-height) and not a fixed height, and how safe areas and the virtual keyboard change the usable height.", + "spec": [ + "`import { DEVICE_PRESETS, hiddenBy, type PresetName, viewportHeights } from './model';` computes the numbers; the model is pure and unit-tested", + "`` and ``", + "`` with a screen-reader caption listing 100lvh, 100svh, 100dvh and the usable height" + ], + "tags": [ + "viewport", + "svh", + "dvh", + "lvh", + "safe-area", + "keyboard", + "mobile", + "simulator", + "concept" + ], + "shape": "wide" +} diff --git a/site/src/demos/concepts/viewport/Simulator.svelte b/site/src/demos/concepts/viewport/Simulator.svelte new file mode 100644 index 00000000..3eb5c78e --- /dev/null +++ b/site/src/demos/concepts/viewport/Simulator.svelte @@ -0,0 +1,124 @@ + + +
+
+ ({ value: k, label: k }))} + /> + + + + +
+ +
+ + + + + + + {#each rows as r (r.unit)} + + + + + + {/each} + + + + + + +
Height each unit resolves to in the chosen state
UnitHeightHidden under UI
100{r.unit}{r.px}px{r.hidden ? `${r.hidden}px` : 'none'}
Usable by content{h.usable}px{h.keyboardInset ? `inset ${h.keyboardInset}px` : 'no inset'}
+
+

An illustrative model with round numbers. Real bar heights and keyboard behavior differ by browser and version: test on devices.

+
+ + diff --git a/site/src/demos/concepts/viewport/model.ts b/site/src/demos/concepts/viewport/model.ts new file mode 100644 index 00000000..179247a9 --- /dev/null +++ b/site/src/demos/concepts/viewport/model.ts @@ -0,0 +1,73 @@ +// The arithmetic behind the viewport concept's demos. An illustrative model, not a browser: it shows how the +// four viewport units, the safe-area insets and a virtual keyboard relate, with numbers the reader can change. +// Real browsers differ (bar heights, keyboard resize modes), so the concept page tells people to test devices. + +export interface DeviceModel { + /** Screen height in CSS px with no browser UI. */ + screen: number; + /** Height of the browser's collapsible top and bottom bars together when they are shown. */ + bars: number; + /** Safe-area insets in CSS px (notch or island, home indicator). */ + safe: { top: number; bottom: number }; + /** Height of the virtual keyboard in CSS px; 0 when closed. */ + keyboard: number; + /** Whether the bars are currently collapsed (the user scrolled). */ + collapsed: boolean; + /** How the browser reacts to the keyboard: shrink the layout viewport or only the visual one. */ + keyboardMode: "resizes-content" | "resizes-visual"; +} + +export interface ViewportHeights { + /** `100lvh`: the large viewport, bars collapsed. `100vh` equals it. */ + lvh: number; + /** `100svh`: the small viewport, bars shown. */ + svh: number; + /** `100dvh`: follows the bars as they show and hide. */ + dvh: number; + /** Height of the visual viewport the person can actually see right now. */ + visible: number; + /** Pixels of the layout viewport covered by the keyboard (what `viewport.keyboardInset` reports). */ + keyboardInset: number; + /** What is left for content: the visible height minus the safe areas the page asked to respect. */ + usable: number; +} + +/** The heights the four units resolve to for one device state. Pure and total: inputs are clamped to sane ranges. */ +export function viewportHeights(d: DeviceModel): ViewportHeights { + const screen = Math.max(0, d.screen); + const bars = Math.min(Math.max(0, d.bars), screen); + const kb = Math.min(Math.max(0, d.keyboard), screen); + const lvh = screen; + const svh = screen - bars; + const dvh = d.collapsed ? lvh : svh; + // With `resizes-content` the layout viewport shrinks; with `resizes-visual` only the visual one does. + const visible = Math.max(0, dvh - kb); + const keyboardInset = d.keyboardMode === "resizes-visual" ? kb : 0; + const safe = Math.max(0, d.safe.top) + (kb > 0 ? 0 : Math.max(0, d.safe.bottom)); + return { lvh, svh, dvh, visible, keyboardInset, usable: Math.max(0, visible - safe) }; +} + +/** Whether a `100` block overflows what is visible (its bottom edge is hidden under browser UI or the keyboard). */ +export function hiddenBy(unit: "lvh" | "svh" | "dvh", d: DeviceModel): number { + const h = viewportHeights(d); + return Math.max(0, h[unit] - h.visible); +} + +/** Presets: common shapes as test samples, not a distribution of devices. */ +export const DEVICE_PRESETS = { + "Narrow phone (320 x 640)": { width: 320, screen: 640, bars: 112, safe: { top: 0, bottom: 0 } }, + "Typical phone (390 x 844)": { + width: 390, + screen: 844, + bars: 110, + safe: { top: 47, bottom: 34 }, + }, + "Short phone (360 x 568)": { width: 360, screen: 568, bars: 100, safe: { top: 0, bottom: 0 } }, + "Phone in landscape (844 x 390)": { + width: 844, + screen: 390, + bars: 60, + safe: { top: 0, bottom: 21 }, + }, +} as const; +export type PresetName = keyof typeof DEVICE_PRESETS; From 2a8e74eb9783fe263adf0bee836426d92abe6a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 08:13:52 -0300 Subject: [PATCH 3/4] feat: index facets for the viewport concept demos 993-995 --- .agents/skills/codeatlas-ui/DEMOS.md | 3 + AGENTS.md | 2 +- packages/ui/scripts/index-backfill.ts | 2 + packages/ui/scripts/index-schema.ts | 2 +- site/public/api/v1/catalog.json | 291 +++++++++++++++++- site/public/api/v1/en/catalog.tsv | 3 + site/public/api/v1/en/e/993.txt | 139 +++++++++ site/public/api/v1/en/e/994.txt | 70 +++++ site/public/api/v1/en/e/995.txt | 47 +++ site/public/api/v1/en/p/993.txt | 14 + site/public/api/v1/en/p/994.txt | 14 + site/public/api/v1/en/p/995.txt | 13 + site/public/api/v1/index.json | 51 +-- site/public/gallery/993.md | 161 ++++++++++ site/public/gallery/994.md | 92 ++++++ site/public/gallery/995.md | 69 +++++ .../demos/concepts/viewport/Hero.meta.json | 32 +- .../concepts/viewport/Keyboard.meta.json | 26 +- .../concepts/viewport/Simulator.meta.json | 32 +- 19 files changed, 1033 insertions(+), 30 deletions(-) create mode 100644 site/public/api/v1/en/e/993.txt create mode 100644 site/public/api/v1/en/e/994.txt create mode 100644 site/public/api/v1/en/e/995.txt create mode 100644 site/public/api/v1/en/p/993.txt create mode 100644 site/public/api/v1/en/p/994.txt create mode 100644 site/public/api/v1/en/p/995.txt create mode 100644 site/public/gallery/993.md create mode 100644 site/public/gallery/994.md create mode 100644 site/public/gallery/995.md diff --git a/.agents/skills/codeatlas-ui/DEMOS.md b/.agents/skills/codeatlas-ui/DEMOS.md index f4271ee4..e018ae8b 100644 --- a/.agents/skills/codeatlas-ui/DEMOS.md +++ b/.agents/skills/codeatlas-ui/DEMOS.md @@ -387,3 +387,6 @@ Every demo has a stable number. Fetch one with `get_demo` (MCP) or https://ui.co | 990 | Brand color | inputs/color-field | ColorField | | 991 | Text color | inputs/color-field | ColorField | | 992 | Label and number | inputs/slider | SliderField | +| 993 | Viewport simulator | concepts/viewport | SegmentedControl, Switch, Slider | +| 994 | A hero that is a floor | concepts/viewport | Button, Switch | +| 995 | A form with the keyboard open | concepts/viewport | Button, StickyActions, TextField | diff --git a/AGENTS.md b/AGENTS.md index 001dd5ae..7b2239a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Every numbered demo (`site/src/demos///.svelte`, id in `si - **Facets:** `kind` + `subtype`, `industry[]`, `style[]` (a `designStyle` id or one of the planned recipes), `motion` (`none` state-free, `subtle` state change, `expressive` entrance/scroll/loop, `immersive` physics/3D/shader/cursor-driven scene), `interaction[]`, `layout`, `colorMode`, `density`, `tech[]`, `cost`, `components[]`, `aliases[]` (lowercase search synonyms), `related[]` (catalog ids), `a11y { reducedMotion, keyboard }`, `added` (ISO date). The closed vocabularies live in `packages/ui/scripts/index-schema.ts`; an unknown value fails the check. - **Inferred, never hand-typed:** `kind`, `subtype`, `components` (must equal the package imports of the demo), `tech`, `cost`, `added`. For a new demo run `bun packages/ui/scripts/index-backfill.ts` (it keeps every value already in a sidecar and fills these), then write `industry`, `style`, `motion`, `interaction`, `layout`, `colorMode`, `density`, `aliases`, `related` and `a11y.keyboard` by reading what the demo renders. Do not copy another demo's block. - **Id ranges:** ids stay append-only; 3 digits to 999, 4 digits from 1000. `ID_RANGES` in `index-schema.ts` reserves a block per kind (components 001-299 and 400-999, motion 300-399 by subtype, sections 1000-2999 in blocks of 100 per subtype, backgrounds 3000-3199, techniques 3200-3399, 3D 3400-3499, shaders 3500-3599, pages 4000-4499, templates 4500-4999). The check fails when an id sits outside the block of its `kind`/`subtype`. Four legacy ids (`256`, `387-389`) are exempt (`LEGACY_OUT_OF_RANGE`); a new id never is. -- **Publishing gate:** a hand-read facet reaches `index.json`, the markdown twins and every filter only when the one-sided 95% upper bound of its error rate on a seeded, folder-stratified audit is at most 5% (`AUDIT`, `errorUpperBound`, `MAX_ERROR_RATE` in `index-schema.ts`). 0 wrong of 73 reads is "at most ~4% wrong", never "0%". A field above the limit stays in the sidecar, marked `"review": { "": "pending" }`, validated but hidden until it is corrected and re-audited with a new seed; flip it in `AUDIT` only with the new numbers. Only 203 of 382 entries were ever re-read; the other 179 hold the first pass's value as corrected by rules, and are unverified beyond them. Today `industry` is pending (5 wrong of 73, upper bound ~14%). Never fix a field by editing the sample you measured it on: re-audit on entries you have not read. +- **Publishing gate:** a hand-read facet reaches `index.json`, the markdown twins and every filter only when the one-sided 95% upper bound of its error rate on a seeded, folder-stratified audit is at most 5% (`AUDIT`, `errorUpperBound`, `MAX_ERROR_RATE` in `index-schema.ts`). 0 wrong of 73 reads is "at most ~4% wrong", never "0%". A field above the limit stays in the sidecar, marked `"review": { "": "pending" }`, validated but hidden until it is corrected and re-audited with a new seed; flip it in `AUDIT` only with the new numbers. Only 203 of 385 entries were ever re-read; the other 182 hold the first pass's value as corrected by rules, and are unverified beyond them. Today `industry` is pending (5 wrong of 73, upper bound ~14%). Never fix a field by editing the sample you measured it on: re-audit on entries you have not read. - **Output:** the manifest build writes `site/public/api/v1/index.json` (every entry with its facets and per-facet counts) and one markdown twin per id at `site/public/gallery/.md`. Both are generated: never edit them; commit the regenerated files. - **Originality:** write every title, summary, alias and prompt in our own words. Never name, link to or credit an external site or product in a demo, sidecar or generated file (`tests/demos-index.test.ts` scans the sidecars). diff --git a/packages/ui/scripts/index-backfill.ts b/packages/ui/scripts/index-backfill.ts index 6870d0a4..12df5914 100644 --- a/packages/ui/scripts/index-backfill.ts +++ b/packages/ui/scripts/index-backfill.ts @@ -39,6 +39,8 @@ const COMPONENT_SUBTYPE: Record = { foundation: "foundation", map: "map", "docs-kit": "display", + // The Concepts pages' simulators teach a design foundation (viewport, motion, targets). + concepts: "foundation", }; /** `kind` and `subtype` from the demo's folder. */ diff --git a/packages/ui/scripts/index-schema.ts b/packages/ui/scripts/index-schema.ts index 374a5a26..1e9cf736 100644 --- a/packages/ui/scripts/index-schema.ts +++ b/packages/ui/scripts/index-schema.ts @@ -262,7 +262,7 @@ export const AUDIT = { * were applied to all entries), `holdout` = 73 random entries not in `first`. `catalog` is the total; the rest, * `catalog - read`, were never re-read. */ - read: { first: 130, holdout: 73, total: 203, catalog: 382, unread: 179 }, + read: { first: 130, holdout: 73, total: 203, catalog: 385, unread: 182 }, fields: { industry: { wrong: 5, checked: 73, review: "pending" as Review }, style: { wrong: 0, checked: 73, review: "published" as Review }, diff --git a/site/public/api/v1/catalog.json b/site/public/api/v1/catalog.json index 7fad9d70..562316bd 100644 --- a/site/public/api/v1/catalog.json +++ b/site/public/api/v1/catalog.json @@ -1,6 +1,6 @@ { "version": "4.5.0", - "count": 382, + "count": 385, "groups": [ { "slug": "inputs", @@ -67,6 +67,12 @@ "first": "800", "last": "800", "count": 1 + }, + { + "slug": "concepts", + "first": "993", + "last": "995", + "count": 3 } ], "elements": [ @@ -34025,6 +34031,289 @@ "interactive": true, "demo": "https://ui.codeatlas.com.br/docs/inputs/slider#label-and-number", "needs": "@codeatlasdev/ui@4.5.0" + }, + { + "id": "993", + "group": "concepts", + "page": "viewport", + "variant": "Simulator", + "slug": "viewport-simulator", + "title": "Viewport simulator", + "description": "Change the device shape, collapse the browser bars, draw edge to edge and raise a keyboard: see what each unit resolves to and what is hidden.", + "summary": "An interactive phone drawing with a device-shape selector, switches for collapsed browser bars and edge-to-edge drawing, a keyboard height slider and a keyboard resize mode, plus a table of what 100lvh, 100svh and 100dvh resolve to and how much of each is hidden.", + "use": "Teaching why a full-height block must be a floor (min-height) and not a fixed height, and how safe areas and the virtual keyboard change the usable height.", + "spec": [ + "`import { DEVICE_PRESETS, hiddenBy, type PresetName, viewportHeights } from './model';` computes the numbers; the model is pure and unit-tested", + "`` and ``", + "`` with a screen-reader caption listing 100lvh, 100svh, 100dvh and the usable height" + ], + "tags": [ + "viewport", + "svh", + "dvh", + "lvh", + "safe-area", + "keyboard", + "mobile", + "simulator", + "concept" + ], + "shape": "wide", + "index": { + "kind": "component", + "subtype": "foundation", + "industry": [ + "general" + ], + "style": [ + "default" + ], + "motion": "subtle", + "interaction": [ + "press", + "drag", + "keyboard" + ], + "layout": "split", + "colorMode": "both", + "density": "regular", + "tech": [ + "css" + ], + "cost": "css", + "components": [ + "SegmentedControl", + "Switch", + "Slider" + ], + "aliases": [ + "viewport units", + "svh dvh lvh", + "safe area", + "virtual keyboard", + "phone simulator" + ], + "related": [ + "994", + "995" + ], + "a11y": { + "reducedMotion": true, + "keyboard": true + }, + "added": "2026-10-25", + "review": { + "industry": "pending" + } + }, + "components": [ + "SegmentedControl", + "Switch", + "Slider" + ], + "imports": [ + { + "from": "@codeatlasdev/ui", + "names": [ + "SegmentedControl", + "Switch", + "Slider" + ] + } + ], + "interactive": true, + "demo": "https://ui.codeatlas.com.br/docs/concepts/viewport#viewport-simulator", + "needs": "@codeatlasdev/ui@4.5.0" + }, + { + "id": "994", + "group": "concepts", + "page": "viewport", + "variant": "Hero", + "slug": "a-hero-that-is-a-floor", + "title": "A hero that is a floor", + "description": "Turn on 200% text and a longer translation: the hero grows and the page scrolls. Turn off the cue and the next section disappears from view.", + "summary": "A phone-sized scrollable frame holding a hero (eyebrow, heading, lead, filled Button) and a second section, with a switch for 200% text and a long translation and a switch for a continuation cue.", + "use": "Showing that a hero is a floor (min-height) so larger text grows it instead of clipping, and that without a visible cue the next section can read as the end of the page.", + "spec": [ + "`` swaps the title to the h2 token and the lead to a longer string", + "`` toggles a `.is-peek` class on the second section", + "The frame is `inline-size: min(100%, 22rem)`, `block-size: 26rem`, `overflow-y: auto`, `tabindex=\"0\"`, `role=\"region\"` with an `aria-label`", + "`.hero` uses `min-block-size`, never `height`; `` is the primary action", + "The second section is `

How it works

` with one paragraph, separated by a `border-block-start`" + ], + "tags": [ + "viewport", + "hero", + "min-height", + "false-floor", + "scroll-cue", + "reflow", + "concept" + ], + "shape": "tall", + "index": { + "kind": "component", + "subtype": "foundation", + "industry": [ + "general" + ], + "style": [ + "default" + ], + "motion": "subtle", + "interaction": [ + "press", + "scroll", + "keyboard" + ], + "layout": "stack", + "colorMode": "both", + "density": "regular", + "tech": [ + "css" + ], + "cost": "css", + "components": [ + "Button", + "Switch" + ], + "aliases": [ + "full height hero", + "min-height floor", + "first screen", + "text scaling", + "continuation cue" + ], + "related": [ + "993", + "995" + ], + "a11y": { + "reducedMotion": true, + "keyboard": true + }, + "added": "2026-10-25", + "review": { + "industry": "pending" + } + }, + "components": [ + "Button", + "Switch" + ], + "imports": [ + { + "from": "@codeatlasdev/ui", + "names": [ + "Button", + "Switch" + ] + } + ], + "interactive": true, + "demo": "https://ui.codeatlas.com.br/docs/concepts/viewport#a-hero-that-is-a-floor", + "needs": "@codeatlasdev/ui@4.5.0" + }, + { + "id": "995", + "group": "concepts", + "page": "viewport", + "variant": "Keyboard", + "slug": "a-form-with-the-keyboard-open", + "title": "A form with the keyboard open", + "description": "On a phone, focus a field. The page scrolls and the action bar stays above the keyboard.", + "summary": "A scrollable account form with three TextFields (work email, company, role) and a StickyActions bar with a stretched Continue button, wrapped in the keyboardAware action.", + "use": "A form that stays operable with the on-screen keyboard open: the page scrolls, the focused field comes into view and the action bar rides above the keyboard and the safe area.", + "spec": [ + "``, then `Company` and `Role` fields with matching `autocomplete`", + "A filler `div` of at least `8rem` so the form scrolls", + "`` holds `` is the primary action +- The second section is `

How it works

` with one paragraph, separated by a `border-block-start` +Tags: viewport, hero, min-height, false-floor, scroll-cue, reflow, concept +Needs: @codeatlasdev/ui@4.5.0 +Components: Button, Switch +Live demo: https://ui.codeatlas.com.br/docs/concepts/viewport#a-hero-that-is-a-floor + + + +
+
+ + +
+ +
+
+

Inventory and point of sale

+

One system for the counter, the stock room and the kitchen

+

+ {large + ? 'Sell at the counter, count stock in the back and send orders to the kitchen from the same screen, with the same people and the same numbers, even when the connection drops for a while.' + : 'Sell, count stock and send orders from one screen.'} +

+ +
+
+

How it works

+

Three modules, one data model. Content below the first screen is reachable by scrolling and by the link above.

+
+
+
+ + diff --git a/site/public/api/v1/en/e/995.txt b/site/public/api/v1/en/e/995.txt new file mode 100644 index 00000000..13ab0607 --- /dev/null +++ b/site/public/api/v1/en/e/995.txt @@ -0,0 +1,47 @@ + +Element: #995 A form with the keyboard open (concepts/viewport) +What it shows: A scrollable account form with three TextFields (work email, company, role) and a StickyActions bar with a stretched Continue button, wrapped in the keyboardAware action. +When to use: A form that stays operable with the on-screen keyboard open: the page scrolls, the focused field comes into view and the action bar rides above the keyboard and the safe area. +Spec: +- ``, then `Company` and `Role` fields with matching `autocomplete` +- A filler `div` of at least `8rem` so the form scrolls +- `` holds ` + + + + diff --git a/site/public/api/v1/en/p/993.txt b/site/public/api/v1/en/p/993.txt new file mode 100644 index 00000000..54644492 --- /dev/null +++ b/site/public/api/v1/en/p/993.txt @@ -0,0 +1,14 @@ +Use @codeatlasdev/ui demo #993 "Viewport simulator" (concepts/viewport). +What it shows: An interactive phone drawing with a device-shape selector, switches for collapsed browser bars and edge-to-edge drawing, a keyboard height slider and a keyboard resize mode, plus a table of what 100lvh, 100svh and 100dvh resolve to and how much of each is hidden. When to use: Teaching why a full-height block must be a floor (min-height) and not a fixed height, and how safe areas and the virtual keyboard change the usable height. +Spec: +- `import { DEVICE_PRESETS, hiddenBy, type PresetName, viewportHeights } from './model';` computes the numbers; the model is pure and unit-tested +- `` and `` +- `` with a screen-reader caption listing 100lvh, 100svh, 100dvh and the usable height +Check first: Svelte 5 + @codeatlasdev/ui installed? theme in src/lib/theme.ts? Kit 2 or 3? +Apply: import only from "@codeatlasdev/ui[/charts|/map|/sound|/table|/code|/forms]"; keep component names, props and + variants of the reference as they are; no layout CSS, --ca-* tokens only (SKILL.md rules); + replace sample data/copy with the project's; keep the first comment line. +Report: #993 Viewport simulator → file, what changed, https://ui.codeatlas.com.br/docs/concepts/viewport#viewport-simulator; what you could not check (light/dark, 375px, keyboard, reduced motion). +Reference: https://ui.codeatlas.com.br/api/v1/en/e/993.txt Components API: ca-ui-mcp get_component(SegmentedControl) diff --git a/site/public/api/v1/en/p/994.txt b/site/public/api/v1/en/p/994.txt new file mode 100644 index 00000000..450b4dbc --- /dev/null +++ b/site/public/api/v1/en/p/994.txt @@ -0,0 +1,14 @@ +Use @codeatlasdev/ui demo #994 "A hero that is a floor" (concepts/viewport). +What it shows: A phone-sized scrollable frame holding a hero (eyebrow, heading, lead, filled Button) and a second section, with a switch for 200% text and a long translation and a switch for a continuation cue. When to use: Showing that a hero is a floor (min-height) so larger text grows it instead of clipping, and that without a visible cue the next section can read as the end of the page. +Spec: +- `` swaps the title to the h2 token and the lead to a longer string +- `` toggles a `.is-peek` class on the second section +- The frame is `inline-size: min(100%, 22rem)`, `block-size: 26rem`, `overflow-y: auto`, `tabindex="0"`, `role="region"` with an `aria-label` +- `.hero` uses `min-block-size`, never `height`; `` is the primary action +- The second section is `

How it works

` with one paragraph, separated by a `border-block-start` +Check first: Svelte 5 + @codeatlasdev/ui installed? theme in src/lib/theme.ts? Kit 2 or 3? +Apply: import only from "@codeatlasdev/ui[/charts|/map|/sound|/table|/code|/forms]"; keep component names, props and + variants of the reference as they are; no layout CSS, --ca-* tokens only (SKILL.md rules); + replace sample data/copy with the project's; keep the first comment line. +Report: #994 A hero that is a floor → file, what changed, https://ui.codeatlas.com.br/docs/concepts/viewport#a-hero-that-is-a-floor; what you could not check (light/dark, 375px, keyboard, reduced motion). +Reference: https://ui.codeatlas.com.br/api/v1/en/e/994.txt Components API: ca-ui-mcp get_component(Button) diff --git a/site/public/api/v1/en/p/995.txt b/site/public/api/v1/en/p/995.txt new file mode 100644 index 00000000..4d66906c --- /dev/null +++ b/site/public/api/v1/en/p/995.txt @@ -0,0 +1,13 @@ +Use @codeatlasdev/ui demo #995 "A form with the keyboard open" (concepts/viewport). +What it shows: A scrollable account form with three TextFields (work email, company, role) and a StickyActions bar with a stretched Continue button, wrapped in the keyboardAware action. When to use: A form that stays operable with the on-screen keyboard open: the page scrolls, the focused field comes into view and the action bar rides above the keyboard and the safe area. +Spec: +- ``, then `Company` and `Role` fields with matching `autocomplete` +- A filler `div` of at least `8rem` so the form scrolls +- `` holds `` is the primary action +- The second section is `

How it works

` with one paragraph, separated by a `border-block-start` + +Needs: @codeatlasdev/ui@4.5.0 +Live demo: https://ui.codeatlas.com.br/docs/concepts/viewport#a-hero-that-is-a-floor +Reference: https://ui.codeatlas.com.br/api/v1/en/e/994.txt + +## Source + +```svelte + + +
+
+ + +
+ +
+
+

Inventory and point of sale

+

One system for the counter, the stock room and the kitchen

+

+ {large + ? 'Sell at the counter, count stock in the back and send orders to the kitchen from the same screen, with the same people and the same numbers, even when the connection drops for a while.' + : 'Sell, count stock and send orders from one screen.'} +

+ +
+
+

How it works

+

Three modules, one data model. Content below the first screen is reachable by scrolling and by the link above.

+
+
+
+ + +``` diff --git a/site/public/gallery/995.md b/site/public/gallery/995.md new file mode 100644 index 00000000..c95dd00a --- /dev/null +++ b/site/public/gallery/995.md @@ -0,0 +1,69 @@ + +# #995 A form with the keyboard open + +A scrollable account form with three TextFields (work email, company, role) and a StickyActions bar with a stretched Continue button, wrapped in the keyboardAware action. + +When to use: A form that stays operable with the on-screen keyboard open: the page scrolls, the focused field comes into view and the action bar rides above the keyboard and the safe area. + +## Facets + +- Kind: component / foundation +- Style: default +- Motion: none +- Interaction: scroll, keyboard, press +- Layout: single-column +- Color mode: both +- Density: regular +- Tech: css (cost: css) +- Reduced motion: handled; keyboard: operable +- Components: Button, StickyActions, TextField +- Also searched as: on-screen keyboard, sticky action bar, keyboard aware form, mobile form + +## Spec + +- ``, then `Company` and `Role` fields with matching `autocomplete` +- A filler `div` of at least `8rem` so the form scrolls +- `` holds ` + + + + +``` diff --git a/site/src/demos/concepts/viewport/Hero.meta.json b/site/src/demos/concepts/viewport/Hero.meta.json index 9da1f3e6..a3638885 100644 --- a/site/src/demos/concepts/viewport/Hero.meta.json +++ b/site/src/demos/concepts/viewport/Hero.meta.json @@ -9,5 +9,35 @@ "The second section is `

How it works

` with one paragraph, separated by a `border-block-start`" ], "tags": ["viewport", "hero", "min-height", "false-floor", "scroll-cue", "reflow", "concept"], - "shape": "tall" + "shape": "tall", + "index": { + "kind": "component", + "subtype": "foundation", + "industry": ["general"], + "style": ["default"], + "motion": "subtle", + "interaction": ["press", "scroll", "keyboard"], + "layout": "stack", + "colorMode": "both", + "density": "regular", + "tech": ["css"], + "cost": "css", + "components": ["Button", "Switch"], + "aliases": [ + "full height hero", + "min-height floor", + "first screen", + "text scaling", + "continuation cue" + ], + "related": ["993", "995"], + "a11y": { + "reducedMotion": true, + "keyboard": true + }, + "added": "2026-10-25", + "review": { + "industry": "pending" + } + } } diff --git a/site/src/demos/concepts/viewport/Keyboard.meta.json b/site/src/demos/concepts/viewport/Keyboard.meta.json index 38e09b8c..57751735 100644 --- a/site/src/demos/concepts/viewport/Keyboard.meta.json +++ b/site/src/demos/concepts/viewport/Keyboard.meta.json @@ -17,5 +17,29 @@ "safe-area", "concept" ], - "shape": "tall" + "shape": "tall", + "index": { + "kind": "component", + "subtype": "foundation", + "industry": ["general"], + "style": ["default"], + "motion": "none", + "interaction": ["scroll", "keyboard", "press"], + "layout": "single-column", + "colorMode": "both", + "density": "regular", + "tech": ["css"], + "cost": "css", + "components": ["Button", "StickyActions", "TextField"], + "aliases": ["on-screen keyboard", "sticky action bar", "keyboard aware form", "mobile form"], + "related": ["993", "994"], + "a11y": { + "reducedMotion": true, + "keyboard": true + }, + "added": "2026-10-25", + "review": { + "industry": "pending" + } + } } diff --git a/site/src/demos/concepts/viewport/Simulator.meta.json b/site/src/demos/concepts/viewport/Simulator.meta.json index 6237c3dc..102c0c15 100644 --- a/site/src/demos/concepts/viewport/Simulator.meta.json +++ b/site/src/demos/concepts/viewport/Simulator.meta.json @@ -19,5 +19,35 @@ "simulator", "concept" ], - "shape": "wide" + "shape": "wide", + "index": { + "kind": "component", + "subtype": "foundation", + "industry": ["general"], + "style": ["default"], + "motion": "subtle", + "interaction": ["press", "drag", "keyboard"], + "layout": "split", + "colorMode": "both", + "density": "regular", + "tech": ["css"], + "cost": "css", + "components": ["SegmentedControl", "Switch", "Slider"], + "aliases": [ + "viewport units", + "svh dvh lvh", + "safe area", + "virtual keyboard", + "phone simulator" + ], + "related": ["994", "995"], + "a11y": { + "reducedMotion": true, + "keyboard": true + }, + "added": "2026-10-25", + "review": { + "industry": "pending" + } + } } From 6f4de9f543a7395969896bb5984b60a6fffb74e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 08:21:30 -0300 Subject: [PATCH 4/4] fix: llms-full props summary keeps the table, not the prose after it --- .changeset/llms-full-props-only.md | 5 +++++ packages/ui/scripts/llms-full.ts | 2 ++ packages/ui/tests/llms-budget.test.ts | 22 +++++++++++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .changeset/llms-full-props-only.md diff --git a/.changeset/llms-full-props-only.md b/.changeset/llms-full-props-only.md new file mode 100644 index 00000000..ba863636 --- /dev/null +++ b/.changeset/llms-full-props-only.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": patch +--- + +`llms-full.txt` keeps only the props table of each non-core page. The summary also carried the prose and examples that follow a table (the SEO page alone added 9 KB), which pushed the file past its size cap once the Concepts pages were added; the descriptions stay in each page's own file. diff --git a/packages/ui/scripts/llms-full.ts b/packages/ui/scripts/llms-full.ts index c8dfe078..90ab9311 100644 --- a/packages/ui/scripts/llms-full.ts +++ b/packages/ui/scripts/llms-full.ts @@ -45,8 +45,10 @@ export function propsSummary(markdown: string): string { .split(/\n(?=### )/) .filter((block) => /^### [^\n]*\n\n\| Prop \|/.test(block)) .map((block) => + // The heading and the table only: prose and examples after the table are in the page's file. block .split("\n") + .filter((line, i) => i < 2 || line.startsWith("|")) .map((line) => { if (!line.startsWith("|")) return line; // Cells are split on `|` that is not escaped as `\|` (a union type in a cell). diff --git a/packages/ui/tests/llms-budget.test.ts b/packages/ui/tests/llms-budget.test.ts index 7320775e..bdbb6e74 100644 --- a/packages/ui/tests/llms-budget.test.ts +++ b/packages/ui/tests/llms-budget.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { buildLlmsPage, llmsPagePath } from "../scripts/ai-entry.ts"; -import { buildLlmsFull, CORE_PAGES } from "../scripts/llms-full.ts"; +import { buildLlmsFull, CORE_PAGES, propsSummary } from "../scripts/llms-full.ts"; import { demoSources, mdxToMarkdown } from "../scripts/mdx-to-markdown.ts"; // llms-full.txt is the file an agent pastes whole into its context window, so its size is a hard limit of @@ -101,6 +101,26 @@ describe("llms-full.txt has a fixed shape (#436)", () => { expect(s).not.toContain("| Description |"); }); + it("a props table keeps only the table: the prose that follows it stays in the page's file", () => { + const md = [ + "### Props", + "", + "| Prop | Type | Default | Description |", + "|---|---|---|---|", + "| `title` | `string` | required | The page title. |", + "", + "A paragraph that explains the table and belongs to the page, not to the summary.", + "", + "```svelte", + "", + "```", + ].join("\n"); + const out = propsSummary(md); + expect(out).toContain("| `title` | `string` | required |"); + expect(out).not.toContain("A paragraph that explains"); + expect(out).not.toContain(" { const synthetic = (slug: string, group: string, extra = 0) => ({ slug,