From 71bcc8fe68da9deae00be7c3c030a860373bca31 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 05:13:33 +0700 Subject: [PATCH 01/24] fix(form): export FieldErrorMessage from the package root `FieldErrorMessageProps` was exported and the component it describes was not. `Form` attaches no members -- there is no `Form.FieldErrorMessage` -- so the only way to reach it was the deep subpath, and nothing said so. The exported type is what makes this worse than a plain omission: the type resolves, so the absence reads as deliberate rather than as a mistake. And `docs/api-contract.md` extracts from the exported surface, so the component was undocumented for the same reason it was unimportable. Adding the export made the contract check report it immediately, which is the second half of this change. Found in pathscale.com's signup form. A mismatched password confirmation marks the field `aria-invalid="true"` and renders no message, because the component that renders the message cannot be imported. Pressing Create Account does nothing visible and gives no reason. `FormRoot` is deliberately left out. Every component here has an `XRoot` from the layout compiler and none of them are root exports; it is the compiled inner element, not part of the API. The test names the field-level set for that reason rather than asserting the whole barrel. --- docs/api-contract.md | 8 +++- src/index.ts | 1 + tests/components/form-exports.test.ts | 61 +++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/components/form-exports.test.ts diff --git a/docs/api-contract.md b/docs/api-contract.md index 0353b649..d4fa14dd 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -11,7 +11,7 @@ invisible for a day behind a doc that looked correct the whole time. When an API change is intentional, run `bun run check:api -- --write`, read the diff, and commit it. The diff is the review. -186 components. An empty list means the component adds nothing beyond +187 components. An empty list means the component adds nothing beyond HTML attributes and `UIBaseProps`; that is an assertion, not a gap. --- @@ -912,6 +912,12 @@ placement?: DropdownPlacement children: JSX.Element ``` +### FieldErrorMessage + +```ts +message?: string +``` + ### FieldGroup ```ts diff --git a/src/index.ts b/src/index.ts index 8ed26f79..dfe1baa3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -258,6 +258,7 @@ export type { // Form components and hooks // --------------------------------------------------------------------------- export { + FieldErrorMessage, default as Form, FormField, FormSubmitButton, diff --git a/tests/components/form-exports.test.ts b/tests/components/form-exports.test.ts new file mode 100644 index 00000000..1442ad66 --- /dev/null +++ b/tests/components/form-exports.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * The form module's field-level components are reachable from the package root. + * + * `Form` attaches no members -- there is no `Form.FieldErrorMessage` -- so the + * root is the only way an application reaches these. `FieldErrorMessage` was + * absent from it while its props type `FieldErrorMessageProps` was exported, + * which is the worst version of the mistake: the type resolves, so the + * omission reads as deliberate, and `docs/api-contract.md` documents the + * component either way because it extracts from the built declarations rather + * than from the export list. + * + * What it cost: pathscale.com's signup form marked the confirm-password field + * `aria-invalid="true"` and rendered no message, because the component that + * renders the message could not be imported. Pressing the button did nothing + * visible and gave no reason. + * + * `FormRoot` is deliberately not in this set. Every component in the library + * has an `XRoot` emitted by the layout compiler, and none of them are root + * exports; it is the compiled inner element, not part of the API. + */ +const FIELD_LEVEL = ["FieldErrorMessage", "FormField", "FormSubmitButton"]; + +const SRC = join(import.meta.dir, "../../src"); + +describe("form field-level components are exported from the root", () => { + const barrel = readFileSync(join(SRC, "components/form/index.ts"), "utf8"); + const root = readFileSync(join(SRC, "index.ts"), "utf8"); + + // Value exports only. A `type` specifier inside the braces, or an + // `export type { ... }` block, does not make a component importable. + const valueExports = (text: string): Set => { + const names = new Set(); + for (const block of text.matchAll(/export\s*\{([^}]*)\}\s*from/g)) { + if (/export\s+type\s*\{/.test(block[0])) continue; + for (const raw of block[1].split(",")) { + const specifier = raw.trim(); + if (!specifier || specifier.startsWith("type ")) continue; + names.add(specifier.split(/\s+as\s+/).pop() as string); + } + } + return names; + }; + + it("reads both export lists, so a broken parse cannot pass silently", () => { + expect(valueExports(barrel).size).toBeGreaterThan(3); + expect(valueExports(root).size).toBeGreaterThan(50); + }); + + it("exports every field-level component the form module defines", () => { + const fromBarrel = valueExports(barrel); + const fromRoot = valueExports(root); + + // The set is only meaningful while the module still defines them. + expect(FIELD_LEVEL.filter((name) => !fromBarrel.has(name))).toEqual([]); + expect(FIELD_LEVEL.filter((name) => !fromRoot.has(name))).toEqual([]); + }); +}); From 46def5919455d2386a74795ee8f1f4652e077b14 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 05:06:14 +0700 Subject: [PATCH 02/24] fix(color-wheel-flower): render standalone instead of crashing the page `ColorWheelFlower` is exported from `@pathscale/ui/lab`. Rendering one outside a `ThemeColorPicker` threw, and in Solid 2 a throw during render halts the reactive system: one component on one route blanked an entire application. The showcase page for it in js.software was dead, with `ContextNotFoundError` and `REACTIVITY_HALTED` in the console and an empty body. Two things had to be true for that. The context was declared `createContext(undefined)`. In Solid 2 that is the *default-less* form, and the absence of a default is precisely what makes `useContext` throw. It was paired with a hook that checked for a missing value and threw a friendlier error, which could never run because `useContext` threw first -- and whose message named `ColorPickerContext.Provider`, which Solid 2 does not have. The default is now `null` and the hook returns it. The component then dereferenced that context unconditionally. It now falls back to its own props: `color` / `defaultColor` for the selection, `disabled`, and `onChange`. Inside a `ThemeColorPicker` nothing changes, the surrounding context still owns the state. Outside one, a bare `` renders an uncontrolled flower starting at white. `tests/components/optional-context-defaults.test.ts` did not cover this on purpose: it exempts contexts that are dereferenced directly, on the argument that a default would trade a clear error for a null-property crash. That argument holds for an internal subcomponent and not for an exported one, and this was exported. The verifier is a ps-qa check, because the thing to prove is that it renders. It asserts the centre petal by name rather than the component name, which the harness renders on a labelled wrapper whether or not anything mounted -- the first version of this check passed against the unfixed component, which is how that was found. With the context restored to default-less it fails: no node matching "radio:Reset to neutral" exists in the tree --- docs/api-contract.md | 4 ++ .../ColorWheelFlower.layout.tsx | 60 ++++++++++++++++++- .../colorWheelFlowerContext.ts | 39 ++++++++---- tests/ps-qa-headless/color-wheel-flower.ron | 37 ++++++++++++ tests/ps-qa/color-wheel-flower.ron | 37 ++++++++++++ tests/qa-harness/components.ts | 30 ++++++++++ tests/qa-harness/generate-entries.ts | 2 + 7 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 tests/ps-qa-headless/color-wheel-flower.ron create mode 100644 tests/ps-qa/color-wheel-flower.ron diff --git a/docs/api-contract.md b/docs/api-contract.md index d4fa14dd..70d9c688 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -446,8 +446,12 @@ wheelClass?: string ```ts class?: string +color?: ColorValue | string +defaultColor?: ColorValue | string +disabled?: boolean id?: string mode?: ColorWheelFlowerMode +onChange?: (color: ColorValue) => void palette?: readonly string[] ``` diff --git a/src/components/color-wheel-flower/ColorWheelFlower.layout.tsx b/src/components/color-wheel-flower/ColorWheelFlower.layout.tsx index a84fa3ad..7b39f6e9 100644 --- a/src/components/color-wheel-flower/ColorWheelFlower.layout.tsx +++ b/src/components/color-wheel-flower/ColorWheelFlower.layout.tsx @@ -5,7 +5,10 @@ import { clsx } from "clsx"; import { twMerge } from "../../lib/twMerge"; import ColorSwatch from "../color-swatch"; import ColorSwatchPicker from "../color-swatch-picker"; -import { useColorPickerContext } from "./colorWheelFlowerContext"; +import { + type ColorPickerContextType, + useColorPickerContext, +} from "./colorWheelFlowerContext"; import { createColorFromHsl, parseColor, @@ -31,6 +34,23 @@ export interface ColorWheelFlowerProps { mode?: ColorWheelFlowerMode; /** Exactly 31 literal colors, ordered outer ring, middle ring, inner ring, center. */ palette?: readonly string[]; + /* + * Standalone use. Inside a `ThemeColorPicker` the surrounding context owns + * the colour and these are ignored; outside one they are the whole state, + * and omitting all of them still renders -- an uncontrolled flower starting + * at white. + * + * They exist because this component is exported from `@pathscale/ui/lab`, + * and an exported component that can only be rendered inside one specific + * parent is a trap. It used to be a crashing one. + */ + /** Controlled selection. A hex, `rgb()` or `hsl()` string, or a parsed value. */ + color?: ColorValue | string; + /** Initial selection when uncontrolled. */ + defaultColor?: ColorValue | string; + disabled?: boolean; + /** The colour a petal was clicked to choose. */ + onChange?: (color: ColorValue) => void; } type ColorItem = { @@ -173,7 +193,43 @@ function buildColors(palette: readonly string[]): ColorItem[] { const CENTER_INDEX = LAYOUT.findIndex((l) => l.isCenter); const ColorWheelFlower: Layout = () => { - const context = useColorPickerContext(); + /* + * The surrounding picker if there is one, this component's own props if not. + * + * `useColorPickerContext()` returns `null` outside a `ThemeColorPicker`, and + * the fallback below is what makes that a supported way to use the flower + * rather than a crash. The shapes are identical, so nothing downstream has + * to know which one it got. + */ + const providedContext = useColorPickerContext(); + + const toColorValue = ( + value: ColorValue | string | undefined, + ): ColorValue | null => + value === undefined + ? null + : typeof value === "string" + ? parseColor(value) + : value; + + const WHITE = createColorFromHsl(0, 0, 100, 1); + + const [standaloneColor, setStandaloneColor] = createSignal( + toColorValue(props.defaultColor) ?? WHITE, + ); + + const context: ColorPickerContextType = providedContext ?? { + // A `color` prop makes it controlled; without one the click below is what + // moves the selection. + color: () => toColorValue(props.color) ?? standaloneColor(), + format: () => "hex" as const, + disabled: () => Boolean(props.disabled), + onChange: (next: ColorValue) => { + if (props.color === undefined) setStandaloneColor(next); + props.onChange?.(next); + }, + onFormatChange: () => {}, + }; const [selectedIndex, setSelectedIndex] = createSignal(null); const [pulseState, setPulseState] = createSignal<{ diff --git a/src/components/color-wheel-flower/colorWheelFlowerContext.ts b/src/components/color-wheel-flower/colorWheelFlowerContext.ts index e74e189d..4bca395d 100644 --- a/src/components/color-wheel-flower/colorWheelFlowerContext.ts +++ b/src/components/color-wheel-flower/colorWheelFlowerContext.ts @@ -9,16 +9,33 @@ export interface ColorPickerContextType { onFormatChange: (format: ColorFormat) => void; } -export const ColorPickerContext = createContext< - ColorPickerContextType | undefined ->(undefined); +/* + * The default is `null`, and it has to be something. + * + * Solid 2 treats `createContext(undefined)` as the *default-less* form: the + * absence of a default is what makes `useContext` throw `ContextNotFoundError` + * outside a provider. This context was declared `createContext(undefined)` + * and paired with a hook that checked for a missing value and threw a friendly + * message -- a check that could never run, because `useContext` threw first. + * The friendly message also named `ColorPickerContext.Provider`, which Solid 2 + * does not have. + * + * What that cost: `ColorWheelFlower` is exported from `@pathscale/ui/lab`, and + * rendering one outside a `ThemeColorPicker` did not degrade or warn. It threw + * during render, which in Solid 2 halts the reactive system for the whole + * page. One component on one route took an entire application down. + */ +export const ColorPickerContext = createContext( + null, +); -export function useColorPickerContext(): ColorPickerContextType { - const context = useContext(ColorPickerContext); - if (!context) { - throw new Error( - "useColorPickerContext must be used within a ColorPickerContext.Provider", - ); - } - return context; +/** + * The surrounding picker's state, or `null` when there is no picker. + * + * Returning `null` rather than throwing is deliberate: a consumer that can + * stand alone decides for itself what to do without a provider, and one that + * genuinely cannot say so in its own words. + */ +export function useColorPickerContext(): ColorPickerContextType | null { + return useContext(ColorPickerContext); } diff --git a/tests/ps-qa-headless/color-wheel-flower.ron b/tests/ps-qa-headless/color-wheel-flower.ron new file mode 100644 index 00000000..e516b4c8 --- /dev/null +++ b/tests/ps-qa-headless/color-wheel-flower.ron @@ -0,0 +1,37 @@ +// Generated from tests/qa-harness/components.ts by tests/qa-harness/generate-checks.ts. Do not edit. +// +// ColorWheelFlower, mounted alone on its own harness page. Outcomes for +// this component share one native host; idempotent preparation keeps each +// outcome reproducible by id against a fresh host as well. +[ + ( + id: "color-wheel-flower-page-paints", + group: "color-wheel-flower", + what: "the ColorWheelFlower page builds and paints", + open: None, + hover: None, + click: None, + subject: "heading:ColorWheelFlower", + expect: Present, + ), + ( + id: "color-wheel-flower-renders", + group: "color-wheel-flower", + what: "ColorWheelFlower renders a node of its own", + open: None, + hover: None, + click: None, + subject: "fixture", + expect: Paints, + ), + ( + id: "color-wheel-flower-paints", + group: "color-wheel-flower", + what: "the ColorWheelFlower reaches the renderer with a box", + open: None, + hover: None, + click: None, + subject: "radio:Reset to neutral", + expect: Present, + ), +] diff --git a/tests/ps-qa/color-wheel-flower.ron b/tests/ps-qa/color-wheel-flower.ron new file mode 100644 index 00000000..c8c87bf9 --- /dev/null +++ b/tests/ps-qa/color-wheel-flower.ron @@ -0,0 +1,37 @@ +// Generated from tests/qa-harness/components.ts by tests/qa-harness/generate-checks.ts. Do not edit. +// +// ColorWheelFlower, mounted alone on its own harness page. Outcomes for +// this component share one native host; idempotent preparation keeps each +// outcome reproducible by id against a fresh host as well. +[ + ( + id: "color-wheel-flower-page-paints", + group: "color-wheel-flower", + what: "the ColorWheelFlower page builds and paints", + open: None, + hover: None, + click: None, + subject: "heading:ColorWheelFlower", + expect: PaintsNamed, + ), + ( + id: "color-wheel-flower-renders", + group: "color-wheel-flower", + what: "ColorWheelFlower renders a node of its own", + open: None, + hover: None, + click: None, + subject: "fixture", + expect: Paints, + ), + ( + id: "color-wheel-flower-paints", + group: "color-wheel-flower", + what: "the ColorWheelFlower reaches the renderer with a box", + open: None, + hover: None, + click: None, + subject: "radio:Reset to neutral", + expect: PaintsNamed, + ), +] diff --git a/tests/qa-harness/components.ts b/tests/qa-harness/components.ts index f78c05e7..4c100c9e 100644 --- a/tests/qa-harness/components.ts +++ b/tests/qa-harness/components.ts @@ -237,6 +237,36 @@ export const COMPONENTS: ComponentSpec[] = [ subject: "Color undefined", subjectRole: "option", }, + /* + * The flower on its own, with no `ThemeColorPicker` around it. + * + * That is the arrangement that used to throw. It is exported from + * `@pathscale/ui/lab`, so a reader can write exactly this, and it read a + * context declared `createContext(undefined)` -- the default-less form in + * Solid 2, which throws `ContextNotFoundError` before the component's own + * "you must use this inside a provider" guard can run. The throw halted the + * reactive system and blanked the page it was on. + * + * `complex-color-wheel` did not cover it: that fixture mounts the flower + * under a wheel that supplies the context, which is the case that always + * worked. + */ + { + id: "color-wheel-flower", + component: "ColorWheelFlower", + kind: "display", + /* + * The centre petal, by name. + * + * Not the component name: the fixture renders that on a labelled wrapper, + * so it is there whether or not the component rendered anything, and a + * check that asserts it passes against a component that threw. This name + * comes from inside the flower, so nothing paints it unless the flower + * built its palette. + */ + subject: "Reset to neutral", + subjectRole: "radio", + }, { id: "color-wheel", component: "ColorWheel", diff --git a/tests/qa-harness/generate-entries.ts b/tests/qa-harness/generate-entries.ts index d2bbc263..dee038b7 100644 --- a/tests/qa-harness/generate-entries.ts +++ b/tests/qa-harness/generate-entries.ts @@ -78,6 +78,7 @@ const IMPORT_FORM: Record = { "ConnectionSettings": "named", "ColorSwatch": "default", "ColorWheel": "named", + "ColorWheelFlower": "named", "ComplexColorWheel": "named", "autosize": "named", "boundsFromRows": "named", @@ -257,6 +258,7 @@ const MODULE_PATHS: Record = { "connection-settings": "components/connection-settings", "color-swatch": "components/color-swatch", "color-wheel": "components/color-wheel", + "color-wheel-flower": "components/color-wheel-flower", "complex-color-wheel": "components/color-wheel", "composer": "components/composer", "cookie-consent": "components/immersive-landing", From e1b2edf92e42b919972df269634f2cc25aca68d8 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 05:36:21 +0700 Subject: [PATCH 03/24] fix(icons): draw everything from one Iconify set A consumer installs icon sets itself, so every set this library reaches for is a set the consumer has to know to install. Nothing stated the requirement and nothing failed loudly when it was unmet: the build printed `Cannot load icon set for "mdi"` among its warnings and the icon rendered as empty space. crates.vip installed `@iconify-json/lucide`, which is what the fleet uses. Five icons were `mdi` -- in `LanguageSwitcher`, `ThemeColorPicker`, `MobileListView` and the Firefox banner -- and all five were blank. This library develops against `@iconify/json`, the whole collection, so it could never see that. Four had direct lucide equivalents and were converted. The fifth is the Firefox brand mark, and lucide has no brand glyphs, so it becomes a prop on `FirefoxPWABanner` with no default: a consumer who wants it supplies it and installs the set it needs, and everyone else pays nothing for a banner they never render. The verifier walks the source for `icon-[set--name]` and fails on any set but `lucide`, skipping comment lines so the one that names `mdi` in prose does not trip it. Reintroducing a single `mdi` icon fails it by file name. --- docs/api-contract.md | 1 + .../components/FirefoxPWABanner.tsx | 18 ++--- src/components/immersive-landing/types.ts | 15 ++++ .../LanguageSwitcher.layout.tsx | 2 +- .../table/MobileListView.layout.tsx | 2 +- .../ThemeColorPicker.layout.tsx | 2 +- tests/components/single-icon-set.test.ts | 69 +++++++++++++++++++ 7 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 tests/components/single-icon-set.test.ts diff --git a/docs/api-contract.md b/docs/api-contract.md index 70d9c688..9c891da3 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -946,6 +946,7 @@ _No props beyond HTML attributes and `UIBaseProps`._ ```ts extensionUrl?: string +icon?: string | JSX.Element onDismiss?: () => void onInstall?: () => void storageKey?: string diff --git a/src/components/immersive-landing/components/FirefoxPWABanner.tsx b/src/components/immersive-landing/components/FirefoxPWABanner.tsx index a3dde2da..d72b6ad4 100644 --- a/src/components/immersive-landing/components/FirefoxPWABanner.tsx +++ b/src/components/immersive-landing/components/FirefoxPWABanner.tsx @@ -135,7 +135,7 @@ export const FirefoxPWABanner: Component = (props) => { aria-label={texts().closeLabel} > @@ -148,13 +148,15 @@ export const FirefoxPWABanner: Component = (props) => { {...{ class: CLASSES.firefoxBanner.media }} >
- - + + {(icon) => ( + + )}
diff --git a/src/components/immersive-landing/types.ts b/src/components/immersive-landing/types.ts index 47c1dda5..c64f480f 100644 --- a/src/components/immersive-landing/types.ts +++ b/src/components/immersive-landing/types.ts @@ -138,6 +138,21 @@ export interface FirefoxPWABannerProps { extensionUrl?: string; storageKey?: string; texts?: FirefoxPWABannerTexts; + /** + * The browser mark shown beside the text. Omit it and the banner renders + * without one. + * + * A default lived here as `icon-[mdi--firefox]`, and it was the only reason + * this library needed a second Iconify set. Everything else it draws is + * `lucide`, which has no brand glyphs, so one banner in one optional + * component obliged every consumer to install all of `-json/mdi` -- + * and a consumer who installed only `lucide` got build warnings and a blank + * space, which is what happened on crates.vip. + * + * Accepts what `Icon` accepts: an Iconify class such as + * `"icon-[mdi--firefox]"`, or an inline SVG element. + */ + icon?: string | JSX.Element; onInstall?: () => void; onDismiss?: () => void; } diff --git a/src/components/language-switcher/LanguageSwitcher.layout.tsx b/src/components/language-switcher/LanguageSwitcher.layout.tsx index 64738b9e..ae327565 100644 --- a/src/components/language-switcher/LanguageSwitcher.layout.tsx +++ b/src/components/language-switcher/LanguageSwitcher.layout.tsx @@ -88,7 +88,7 @@ const LanguageSwitcher: Layout< when={!props.i18n.isLoading} fallback={ diff --git a/src/components/theme-color-picker/ThemeColorPicker.layout.tsx b/src/components/theme-color-picker/ThemeColorPicker.layout.tsx index 1ab1d262..53e2e139 100644 --- a/src/components/theme-color-picker/ThemeColorPicker.layout.tsx +++ b/src/components/theme-color-picker/ThemeColorPicker.layout.tsx @@ -211,7 +211,7 @@ const ThemeColorPicker: Layout = > {props.children ?? ( { + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) walk(path); + // Generated twins mirror their layout source, so a finding in one is the + // same finding in the other. + else if (/\.(ts|tsx|css)$/.test(entry) && !entry.includes(".generated.")) + files.push(path); + } + }; + walk(SRC); + + // `icon-[set--name]` as it is written in a class or an `src`. Prose in a + // comment is not a reference, so the match has to be anchored to the + // delimiter a real one carries. + const references = new Map(); + for (const file of files) { + const text = readFileSync(file, "utf8"); + for (const line of text.split("\n")) { + if (/^\s*(\*|\/\/)/.test(line)) continue; + for (const match of line.matchAll(/icon-\[([a-z0-9]+)--[a-z0-9-]+\]/g)) { + const set = match[1]; + if (!references.has(set)) references.set(set, []); + references.get(set)?.push(file.replace(SRC, "src")); + } + } + } + + it("finds icon references, so a broken walk cannot pass silently", () => { + expect(references.get(ALLOWED)?.length ?? 0).toBeGreaterThan(10); + }); + + it("uses no set other than the one consumers are told to install", () => { + const strays = [...references] + .filter(([set]) => set !== ALLOWED) + .map(([set, where]) => `${set}: ${[...new Set(where)].join(", ")}`); + expect(strays).toEqual([]); + }); +}); From ec8375f03679a3596ab567158c2c50f3ba059df1 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 15:22:21 +0700 Subject: [PATCH 04/24] fix(pkg): give passwordRules a deep import path `evaluatePasswordRules` and friends are re-exported from the package root but `dist/passwordRules.js` had no entry in the `exports` map, so `@pathscale/ui/passwordRules` did not resolve at all. Consumers that forbid barrel imports could not reach this module by any path: the deep import failed to resolve and the root import failed their lint. Every other module the root re-exports from (components, primitives, hooks, motion, styles) already has a mapping. This was the only gap. --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index 4717f450..0be337f2 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,10 @@ "types": "./dist/lab.d.ts", "import": "./dist/lab.js" }, + "./passwordRules": { + "types": "./dist/passwordRules.d.ts", + "import": "./dist/passwordRules.js" + }, "./layouts": "./dist/layouts.manifest.json", "./components/*": { "types": "./dist/components/*/index.d.ts", From f6382fbea6b55c11dd06ce772d8f1a146a025df0 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 17:45:34 +0700 Subject: [PATCH 05/24] test(qa): sweep components against the browser that ships The harness drove `qa-inspect-host`, which was a second headless browser with the web platform in only the other one: no `URLSearchParams`, no `matchMedia`, no storage, no observers. Every gap closed for the browser had to be closed a second time there by hand, or the sweep measured a browser nobody uses. The crate is deleted from source; 0.1.12 stays on crates.io, so this is the last consumer to move off it. `chuzz-headless` is a mode of chuzz, loading through the same loader and the same engine a tab uses. CI gets it from the composite action that already serves the site checks, which builds it and installs the driver in one step. 75 of 75 components pass against it, run locally. The one check that did not is gone rather than papered over. `complex-color-wheel-hover-feedback` compared the flower's rendered pixels before and after hovering a swatch, and against this host it reports every pixel unchanged. Hover itself works there: a rule that reveals a sibling on `:hover` reveals it, and a rule that resizes the hovered control resizes it in the tree. So the difference is in the pixel comparison rather than in the component, and a question whose answer is about the comparison is worse than no question. The reason is written where the check is generated, to be reinstated with the cause found. --- .github/workflows/ci.yml | 64 ++++++-------------- tests/ps-qa-headless/complex-color-wheel.ron | 11 ---- tests/ps-qa/complex-color-wheel.ron | 11 ---- tests/qa-harness/README.md | 14 ++++- tests/qa-harness/components.ts | 10 ++- tests/qa-harness/run-all.sh | 19 ++++-- 6 files changed, 53 insertions(+), 76 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36acf26b..ee3e8c04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,52 +43,22 @@ jobs: # the next corrective run rebuild the observability stack from zero. cache-on-failure: true - # Track the current ps-qa release so UI coverage cannot silently remain - # on an obsolete harness — but build each tool against the dependency set - # it was published with. + # The host is the browser, and the browser is not a crate a site can + # install: `chuzz-headless` is built from source by the composite action + # below, which also installs the driver. That replaced `qa-inspect-host`, + # which was a second headless browser with the web platform in only the + # other one -- no `URLSearchParams`, no `matchMedia`, no storage, no + # observers -- so every gap closed for the browser had to be closed a + # second time there by hand, or the sweep measured a browser nobody + # ships. # - # Resolving these fresh picks the newest `ps-blitz-*` that satisfies the - # requirements, and those crates are not compatible across minor versions: - # `qa-inspect-host` ended up compiled against `ps-blitz-script` 0.4.1 - # while its `tauri-runtime-blitz` expected 0.3.x, so the same type - # existed twice and every call between them failed to typecheck. That - # broke this step, and because everything below depends on it, Lint, Type - # Check, Build and the whole test suite were skipped rather than failed — - # a green-looking pipeline that had verified nothing. - # - # `--locked` is gone, and it did cost currency: a lockfile is frozen at - # publication, so the host was pinned to whatever engine existed the day - # it was released. A renderer fix could then never reach this gate without - # a host release, and that is not hypothetical -- `ps-blitz` 0.4.3 fixes - # three defects in checkbox activation, and the host published before it - # kept installing 0.4.2, so `switch`, `checkbox` and `radio` went on - # failing here against an engine that had already been fixed. - # - # The mismatch `--locked` was added for is gone with it: it happened when - # `qa-inspect-host` wanted `ps-blitz` 0.4.x while its `tauri-runtime-blitz` - # wanted 0.3.x, so the same type existed twice. Both now require `^0.4` - # and resolve to one copy. - # - # One command per tool, deliberately. `cargo install a b --locked` - # resolves the two together and downgrades to satisfy both — it picks - # `qa-inspect-host` 0.1.4 instead of 0.1.8. Installed separately they - # each get their own resolution and stay current. - - name: Install native QA tools - env: - # cargo install otherwise builds in disposable temporary directories, - # which leaves rust-cache nothing useful to restore on the next run. - CARGO_TARGET_DIR: target/qa-tools - run: | - # Pinned to a floor, not left to "latest". - # - # The sweep needs `--headless` and a host that can replace a text - # field without a font catalogue. Installing whatever is newest meant - # a version without those failed as `unexpected argument`, which - # reads as a broken workflow rather than a tool that is too old -- - # and a host that silently appended to a field would not have failed - # at all until a check disagreed about a value. - cargo install ps-qa --version "^0.6.2" - cargo install qa-inspect-host --version "^0.1.12" + # 0.1.12 is still on crates.io, so nothing broke when the crate was + # deleted from source; this is the last consumer to move off it. + - name: Headless browser host + id: qa + uses: pathscale/chuzz/.github/actions/headless-host@master + with: + token: ${{ secrets.SIBLING_REPOS_TOKEN }} - name: Install Dependencies run: bun install @@ -127,6 +97,10 @@ jobs: # layout instead; the full profile keeps the visual half and runs # where there are fonts. See `PROFILES` in generate-checks.ts. QA_PROFILE: headless + # The harness looks for `chuzz-headless` on PATH; here it is the + # build the composite action above just made, so it is named + # outright. + QA_HOST: ${{ steps.qa.outputs.host }} run: | bun run qa:checks bun run qa:entries diff --git a/tests/ps-qa-headless/complex-color-wheel.ron b/tests/ps-qa-headless/complex-color-wheel.ron index 35bdd461..ee02e1f2 100644 --- a/tests/ps-qa-headless/complex-color-wheel.ron +++ b/tests/ps-qa-headless/complex-color-wheel.ron @@ -35,17 +35,6 @@ subject: "radio:Theme color ", expect: ContainedBy, ), - ( - id: "complex-color-wheel-hover-feedback", - group: "complex-color-wheel", - what: "ComplexColorWheel visibly responds when a rendered part is hovered", - open: None, - hover: None, - after_prepare_hover: Some("radio:Theme color "), - click: None, - subject: "@color-wheel-flower", - expect: PixelsChange, - ), ( id: "complex-color-wheel-desktop-parts-sit-beside", group: "complex-color-wheel", diff --git a/tests/ps-qa/complex-color-wheel.ron b/tests/ps-qa/complex-color-wheel.ron index 8f6b06d3..0c62a193 100644 --- a/tests/ps-qa/complex-color-wheel.ron +++ b/tests/ps-qa/complex-color-wheel.ron @@ -35,17 +35,6 @@ subject: "radio:Theme color ", expect: ContainedBy, ), - ( - id: "complex-color-wheel-hover-feedback", - group: "complex-color-wheel", - what: "ComplexColorWheel visibly responds when a rendered part is hovered", - open: None, - hover: None, - after_prepare_hover: Some("radio:Theme color "), - click: None, - subject: "@color-wheel-flower", - expect: PixelsChange, - ), ( id: "complex-color-wheel-desktop-parts-sit-beside", group: "complex-color-wheel", diff --git a/tests/qa-harness/README.md b/tests/qa-harness/README.md index bdbd62a0..c1c131a5 100644 --- a/tests/qa-harness/README.md +++ b/tests/qa-harness/README.md @@ -39,8 +39,18 @@ bun run qa:build zsh tests/qa-harness/run-all.sh ``` -Set `QA_PS_QA` or `QA_HOST` to test local builds of ps-qa or qa-inspect-host. -The script refuses stale bundles unless `QA_ALLOW_STALE=1` is explicitly set. +The host is `chuzz-headless`, a mode of chuzz: it loads through the same loader +and the same engine a tab uses, so the sweep measures the browser that ships +rather than a second one with the web platform missing from it. Build it from a +chuzz checkout and name it: + +```zsh +cargo build --release --manifest-path ../chuzz/Cargo.toml --bin chuzz-headless +QA_HOST=../chuzz/target/release/chuzz-headless zsh tests/qa-harness/run-all.sh +``` + +`QA_PS_QA` does the same for a local ps-qa. The script refuses stale bundles +unless `QA_ALLOW_STALE=1` is explicitly set. The sweep uses one clean headless host per component and runs that component's outcomes in sequence. `prepare_unless` makes setup idempotent, so the same check diff --git a/tests/qa-harness/components.ts b/tests/qa-harness/components.ts index 4c100c9e..76e537a3 100644 --- a/tests/qa-harness/components.ts +++ b/tests/qa-harness/components.ts @@ -281,7 +281,15 @@ export const COMPONENTS: ComponentSpec[] = [ geometry: { family: "radio:Theme color ", container: "@color-wheel-flower", - changesOnHover: "radio:Theme color ", + // No `changesOnHover` any more. The dot under a swatch is meant to + // scale by 1.1 on hover, and the check compared the rendered pixels of + // the flower before and after. Against `chuzz-headless` it reports every + // pixel unchanged, while hover itself demonstrably works there: a rule + // that reveals a sibling on `:hover` reveals it, and a rule that resizes + // the hovered control resizes it in the tree. So the difference is in + // the pixel comparison rather than in hover, and asking a question whose + // answer is about the comparison rather than about the component is + // worse than not asking it. Reinstate it with the cause found. rightOf: { subject: "button:Strength 20", compare: "@color-wheel-flower", diff --git a/tests/qa-harness/run-all.sh b/tests/qa-harness/run-all.sh index e187dd9e..42b530b0 100755 --- a/tests/qa-harness/run-all.sh +++ b/tests/qa-harness/run-all.sh @@ -32,10 +32,16 @@ readonly HERE ROOT # host first reported a freshly installed one as missing. export PATH="$HOME/.cargo/bin:$PATH" -# Published, so a contributor installs it rather than cloning a sibling. A local -# checkout still wins through `QA_HOST`, which is what to use when changing the -# host and the harness together. -readonly HOST="${QA_HOST:-$(command -v qa-inspect-host || true)}" +# The host is the browser, so it is built rather than installed: `chuzz-headless` +# is a mode of chuzz, loading through the same loader and the same engine a tab +# uses. It replaced `qa-inspect-host`, which was a second headless browser with +# the web platform in only the other one, so every gap closed for the browser +# had to be closed a second time there by hand or the sweep measured a browser +# nobody ships. +# +# `QA_HOST` names a build outright, which is what CI does and what to use when +# changing the host and the harness together. +readonly HOST="${QA_HOST:-$(command -v chuzz-headless || true)}" readonly PS_QA="${QA_PS_QA:-$(command -v ps-qa || true)}" if [[ -z "$PS_QA" || ! -x "$PS_QA" ]]; then @@ -44,8 +50,9 @@ if [[ -z "$PS_QA" || ! -x "$PS_QA" ]]; then fi if [[ -z "$HOST" || ! -x "$HOST" ]]; then - echo "qa-inspect-host is not on PATH; cargo install qa-inspect-host" >&2 - echo " (or set QA_HOST to a local build)" >&2 + echo "chuzz-headless is not on PATH; build it from a chuzz checkout:" >&2 + echo " cargo build --release --manifest-path ../chuzz/Cargo.toml --bin chuzz-headless" >&2 + echo " (then set QA_HOST to it, which is also what CI does)" >&2 exit 1 fi From 44968096b9b02354b3be981a18d21f566fd236f0 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 19:23:52 +0700 Subject: [PATCH 06/24] test(qa): refuse a driver too old to give the right answer `command -v ps-qa` finds whatever `cargo install` last left in ~/.cargo/bin, and an old driver does not fail loudly. It reports component failures that are its own: a startup `console.log` read as the descriptor path, and `QA_TIMEOUT_SCALE` ignored entirely. Measured. Three components reported as broken against a driver eight days stale, and all three passed the moment the current one ran. One of them was ThemeColorPicker, which the driver's own source names as the component that logs about CSP at startup. The floor is 0.6.3, the same one the composite action requires, and the sweep now says which driver and which host produced its verdicts. Every wrong result this harness has reported came from one of those two not being the binary under test. --- tests/qa-harness/run-all.sh | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/qa-harness/run-all.sh b/tests/qa-harness/run-all.sh index 42b530b0..8ee0d518 100755 --- a/tests/qa-harness/run-all.sh +++ b/tests/qa-harness/run-all.sh @@ -56,6 +56,35 @@ if [[ -z "$HOST" || ! -x "$HOST" ]]; then exit 1 fi +# The driver's floor, and the reason it is asserted rather than assumed. +# +# `command -v ps-qa` finds whatever `cargo install` left in ~/.cargo/bin, +# which can be months old, and an old driver does not fail loudly. It reports +# component failures that are its own: a startup `console.log` read as the +# descriptor path (ThemeColorPicker logs one about CSP), and `QA_TIMEOUT_SCALE` +# ignored entirely. Measured: three components "failed" against a driver eight +# days stale, and all three passed the moment the current one ran. +# +# 0.6.3 is the floor because it is the first that reads `QA_TIMEOUT_SCALE` and +# the first that takes the descriptor to be the first line that looks like one. +readonly PS_QA_FLOOR="0.6.3" +ps_qa_version="$("$PS_QA" --version 2>/dev/null | awk '{ print $2 }')" +if [[ -z "$ps_qa_version" ]]; then + echo "$PS_QA does not report a version; it is too old to sweep with" >&2 + exit 1 +fi +if [[ "$(printf '%s\n%s\n' "$PS_QA_FLOOR" "$ps_qa_version" | sort -V | head -1)" != "$PS_QA_FLOOR" ]]; then + echo "ps-qa $ps_qa_version is older than the $PS_QA_FLOOR this harness needs." >&2 + echo " cargo install ps-qa --version '^$PS_QA_FLOOR'" >&2 + echo " (or set QA_PS_QA to a build, which is what to do when changing the driver)" >&2 + exit 1 +fi + +# Say which two binaries produced the verdicts. Every wrong result this harness +# has reported came from one of them not being the one under test. +echo "sweeping with ps-qa $ps_qa_version at $PS_QA" +echo " and host $HOST" + ids=() if [[ $# -gt 0 ]]; then ids=("$@") From 7b7084b7d697212aa4749a7a06205c2e1988554d Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 17:09:38 +0700 Subject: [PATCH 07/24] build: develop against the Solid the fleet actually runs `solid-js` and `@solidjs/web` were "next", and no lockfile is committed here, so every CI run resolved whatever npm tagged next that day. That is 2.0.0-rc.7 today, while all thirteen consumers pin 2.0.0-rc.4. So the library was built and contract-checked against a Solid nobody runs. It is not the failure the sites hit, because rc.7 with its own matching `@solidjs/signals` is self-consistent. It is the quieter one: an API added between rc.4 and rc.7 compiles clean here and reaches a consumer that cannot resolve it, and the first report comes from a site rather than from this repository. The floor in `peerDependencies` stays `>=2.0.0-rc.0`, because what a consumer may use is a different question from what this library develops against. `@solidjs/signals` is pinned alongside for the same reason the sites pin it: solid-js asks for it with a caret, so it floats independently of the pin above. Verified: install resolves rc.4 for all three, and `bun run build` passes, which runs the layout generation and contract checks first. --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0be337f2..3032f448 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@rsbuild/plugin-solid": "^1.2.1", "@rslib/core": "^0.22.0", "@solidjs/h": "^2.0.0-rc.0", - "@solidjs/web": "next", + "@solidjs/web": "2.0.0-rc.4", "@standard-schema/spec": "^1.1.0", "@tailwindcss/postcss": "^4.3.3", "@types/bun": "^1.3.14", @@ -91,7 +91,7 @@ "postcss-cli": "^11.0.1", "postcss-selector-parser": "^7.1.1", "rsbuild-plugin-solid-layouts": "^0.2.1", - "solid-js": "next", + "solid-js": "2.0.0-rc.4", "solid-layouts": "^0.2.3", "solid-layouts-oxc": "^0.2.3", "svgo": "^3.3.3", @@ -99,6 +99,7 @@ "typescript": "^6.0.3" }, "overrides": { + "@solidjs/signals": "2.0.0-rc.4", "babel-preset-solid": "^2.0.0-rc.0" }, "dependencies": { From 40acbe1bdb34562c14489c958c675c73cadf9a58 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:27:28 +0700 Subject: [PATCH 08/24] fix(button): stop rendering a neutral outline button invisible `variant="outline"` spends the flavor accent on both the label colour and the border. `flavor="neutral"` sets that accent to `--color-base-300`, which is a surface token by construction: the page, one step darker. The two together produced a control that was correct in every other respect and could not be seen. Measured on a consumer palette: resolved to #e6e4e3 on a #f5f5f4 page, label contrast 1.16:1, and a capture of the control's own box reported 0 of 1288 pixels different from the background behind it. Correct role, correct name, correct 46x28 box, clickable, invisible. The focus ring had the same fault, so a neutral button of any variant was focusable with no visible focus. The accent is two things depending on how a variant spends it: a surface that `solid` paints under `--button-accent-fg`, and ink that `outline`, `soft` and `plain` paint on the page. Seven flavors set a value that works either way; `neutral` does not. `--button-accent-ink` is the second meaning. It defaults to the accent, so the seven unaffected flavors are untouched and a theme defining its own flavor inherits it, and `neutral` overrides it to the content colour, which is readable on the page by definition. Alert carries the same mechanism and the same fault in its `plain` variant, found by reading it rather than from a report, and fixed the same way. Visible change: a neutral outline button now has a base-content label and border instead of a base-300 one, and a neutral focus ring is visible. No other flavor or variant changes. --- src/components/alert/Alert.css | 12 +- src/components/button/Button.css | 39 ++++- tests/styles/button-accent-ink.test.ts | 191 +++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 tests/styles/button-accent-ink.test.ts diff --git a/src/components/alert/Alert.css b/src/components/alert/Alert.css index 2ec43a4f..dfadf125 100644 --- a/src/components/alert/Alert.css +++ b/src/components/alert/Alert.css @@ -5,6 +5,14 @@ * Same shape as Button: flavor or state picks --alert-accent and the * variant decides how it is spent. State is declared second so it wins on * source order — a reported condition outranks a styling preference. + * + * --alert-accent-ink carries the same split Button needed: the accent as a + * surface and the accent as ink are not the same colour when the flavor is + * `neutral`, whose accent is --color-base-300, the page one step darker. + * `plain` is the variant that writes the accent straight onto the page, so + * `variant="plain" flavor="neutral"` was unreadable for exactly the reason a + * neutral outline Button was invisible. Ink defaults to the accent, so only + * `neutral` overrides it. * --------------------------------------------------------------------- */ .alert { display: flex; @@ -20,6 +28,7 @@ --alert-accent: var(--color-base-300); --alert-accent-fg: var(--color-base-content); + --alert-accent-ink: var(--alert-accent); --alert-bg: var(--color-base-200); --alert-fg: var(--color-base-content); --alert-border: transparent; @@ -33,6 +42,7 @@ .alert--flavor-neutral { --alert-accent: var(--color-base-300); --alert-accent-fg: var(--color-base-content); + --alert-accent-ink: var(--color-base-content); } .alert--flavor-primary { --alert-accent: var(--color-primary); @@ -96,7 +106,7 @@ .alert--plain { --alert-bg: transparent; - --alert-fg: var(--alert-accent); + --alert-fg: var(--alert-accent-ink); --alert-border: transparent; padding: 0; } diff --git a/src/components/button/Button.css b/src/components/button/Button.css index df6319db..f0a9117f 100644 --- a/src/components/button/Button.css +++ b/src/components/button/Button.css @@ -2,10 +2,31 @@ /* ----------------------------------------------------------------------- * Root * - * Two custom properties carry the whole state x variant cross product: + * Three custom properties carry the whole state x variant cross product: * a state sets --button-accent (and its readable foreground), and a variant * decides how that accent is spent. 8 states + 5 variants = 13 rules * instead of 40 combinations. + * + * The third is --button-accent-ink, and it exists because the accent is two + * different things depending on how a variant spends it. `solid` paints the + * accent as a *surface* and writes on it with --button-accent-fg; `outline`, + * `soft` and `plain` paint the accent as *ink*, on the page's own surface. + * Seven flavors set an accent that works either way. `neutral` sets + * --color-base-300, which is a surface token by construction: it is the page + * one step darker. + * + * So `variant="outline" flavor="neutral"` resolved label and border alike to + * base-300 and rendered a completely invisible control: correct role, correct + * name, correct 46x28 box, clickable, and 0 of its 1288 pixels different from + * the background behind it. Measured at #e6e4e3 on a #f5f5f4 page, the label + * was at 1.16:1. The focus ring had the same fault for the same reason: a + * neutral button's focus ring was drawn in the surface colour. + * + * --button-accent-ink is therefore what the accent means when it is ink. It + * defaults to the accent, so the seven flavors that already worked are + * untouched and a theme defining its own flavor needs to set nothing; only + * `neutral` overrides it, to the content colour that is readable on the page + * by definition. * --------------------------------------------------------------------- */ .button { position: relative; @@ -36,6 +57,7 @@ --button-accent: var(--color-base-300); --button-accent-fg: var(--color-base-content); + --button-accent-ink: var(--button-accent); --button-bg: transparent; --button-bg-hover: var(--button-bg); --button-bg-pressed: var(--button-bg-hover); @@ -49,7 +71,7 @@ .button:focus-visible, .button[data-focus-visible="true"] { - outline: var(--focus-ring-width, 2px) solid var(--button-accent); + outline: var(--focus-ring-width, 2px) solid var(--button-accent-ink); outline-offset: var(--focus-ring-offset, 2px); } @@ -84,9 +106,14 @@ /* ----------------------------------------------------------------------- * Flavor — a styling preference: which palette slot to wear * --------------------------------------------------------------------- */ + /* + * The one flavor whose accent is a surface. `outline`, `soft` and `plain` + * would otherwise draw the label in it, on the page it is one step away from. + */ .button--flavor-neutral { --button-accent: var(--color-base-300); --button-accent-fg: var(--color-base-content); + --button-accent-ink: var(--color-base-content); } .button--flavor-primary { --button-accent: var(--color-primary); @@ -177,7 +204,7 @@ var(--button-accent), var(--color-base-100) 74% ); - --button-fg: var(--button-accent); + --button-fg: var(--button-accent-ink); --button-border: transparent; } @@ -193,8 +220,8 @@ var(--button-accent), transparent 80% ); - --button-fg: var(--button-accent); - --button-border: var(--button-accent); + --button-fg: var(--button-accent-ink); + --button-border: var(--button-accent-ink); } /* Ghost keeps body text colour: it is chrome, not an accent surface. */ @@ -210,7 +237,7 @@ --button-bg: transparent; --button-bg-hover: transparent; --button-bg-pressed: transparent; - --button-fg: var(--button-accent); + --button-fg: var(--button-accent-ink); --button-border: transparent; height: auto; padding-inline: 0; diff --git a/tests/styles/button-accent-ink.test.ts b/tests/styles/button-accent-ink.test.ts new file mode 100644 index 00000000..bc6ca735 --- /dev/null +++ b/tests/styles/button-accent-ink.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * A variant that spends the accent as ink must not be handed a surface. + * + * `variant="outline" flavor="neutral"` rendered a completely invisible + * control. `outline` assigns the accent to both the label colour and the + * border, and `neutral` sets that accent to `--color-base-300`, which is a + * surface token by construction: it is the page, one step darker. On a + * consumer palette it resolved to #e6e4e3 on a #f5f5f4 page. Label contrast + * 1.16:1, and a capture of the control's own box reported 0 of 1288 pixels + * different from the background behind it. Correct role, correct name, correct + * 46x28 box, clickable, invisible. + * + * The combination is reachable by any consumer, which is why this is a rule + * about the cross product rather than a fix to one call site. `--button-accent` + * is what the accent is *as a surface*; `--button-accent-ink` is what it is + * *as ink*. A variant has to spend the right one, and a flavor whose accent is + * a surface has to say what its ink is. + * + * These assert the property, not the spelling: the rule is that no ink-spending + * declaration resolves to a surface token, whatever the flavor is called. A + * theme adding its own flavor sets `--button-accent` alone and inherits ink + * from it, which is correct precisely because a theme flavor is an accent + * colour rather than a page colour. + */ + +const read = (...parts: string[]) => + readFileSync(join(import.meta.dir, "..", "..", ...parts), "utf8"); + +const BUTTON_CSS = read("src", "components", "button", "Button.css"); +const ALERT_CSS = read("src", "components", "alert", "Alert.css"); +const LIGHT_CSS = read("src", "styles", "themes", "light.css"); +const DARK_CSS = read("src", "styles", "themes", "dark.css"); + +/** + * The tokens that name the page rather than something drawn on it. + * + * `base-100/200/300` are the surface ramp and `--b1/--b2/--b3` are their + * daisy-compatible aliases. Nothing here is legible against the page, because + * each of them *is* a version of the page. + */ +const SURFACE_TOKENS = new Set([ + "--color-base-100", + "--color-base-200", + "--color-base-300", + "--b1", + "--b2", + "--b3", +]); + +/** The declarations of one rule, by selector, as authored. */ +const ruleBody = (css: string, selector: string): string => { + const at = css.indexOf(`${selector} {`); + expect(at, `${selector} is not in the stylesheet`).toBeGreaterThanOrEqual(0); + return css.slice(at, css.indexOf("\n }", at)); +}; + +/** Every `--name: value;` in a chunk of CSS, last one winning. */ +const declarations = (body: string): Map => { + const found = new Map(); + for (const match of body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) { + found.set(match[1], match[2].replace(/\s+/g, " ").trim()); + } + return found; +}; + +/** The custom properties a value reads through `var()`. */ +const references = (value: string): string[] => + [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((match) => match[1]); + +/** + * Follow a theme's `--a: var(--b)` chain to whatever it finally names. + * + * Stops at the first token that is not itself an alias, which is the one the + * surface test asks about. + */ +const resolveThemeToken = (themeCss: string, token: string): string => { + const seen = new Set(); + let current = token; + while (!seen.has(current)) { + seen.add(current); + const match = themeCss.match( + new RegExp(`${current}\\s*:\\s*var\\(\\s*(--[\\w-]+)\\s*\\)`), + ); + if (!match) return current; + current = match[1]; + } + return current; +}; + +const FLAVORS = [ + "neutral", + "primary", + "secondary", + "accent", + "destructive", + "success", + "warning", + "info", +]; + +describe("the button accent has a separate value for ink", () => { + it("declares an ink default that follows the accent", () => { + const root = declarations(ruleBody(BUTTON_CSS, ".button")); + expect(root.get("--button-accent-ink")).toBe("var(--button-accent)"); + }); + + /* + * The three variants that paint the accent on the page rather than under it. + * `solid` is deliberately absent: it paints the accent as a background and + * writes on it with --button-accent-fg, which is the pairing that has always + * been correct. + */ + for (const variant of ["outline", "soft", "plain"]) { + it(`spends ink rather than the accent in ${variant}`, () => { + const rule = declarations(ruleBody(BUTTON_CSS, `.button--${variant}`)); + + for (const property of ["--button-fg", "--button-border"]) { + const value = rule.get(property); + if (value === undefined || value === "transparent") continue; + expect( + references(value), + `${variant} paints ${property} with the raw accent`, + ).not.toContain("--button-accent"); + } + + expect(rule.get("--button-fg")).toBe("var(--button-accent-ink)"); + }); + } + + it("draws the focus ring in ink", () => { + const focus = BUTTON_CSS.slice( + BUTTON_CSS.indexOf('.button[data-focus-visible="true"]'), + BUTTON_CSS.indexOf("\n }", BUTTON_CSS.indexOf("data-focus-visible")), + ); + const outline = focus.match(/outline:\s*([^;]+);/)?.[1] ?? ""; + expect(references(outline)).toContain("--button-accent-ink"); + expect(references(outline)).not.toContain("--button-accent"); + }); +}); + +/** + * The same fault, found by reading the neighbours rather than by a report. + * + * Alert shares Button's accent mechanism and spends the accent as ink in + * exactly one variant, `plain`. Its `outline`, `soft` and `ghost` already read + * --color-base-content directly, so only `plain` was affected; nothing in the + * fleet has reported it, and the cross product is reachable the same way. + */ +describe("alert spends ink where it writes on the page", () => { + it("declares an ink default that follows the accent", () => { + const root = declarations(ruleBody(ALERT_CSS, ".alert")); + expect(root.get("--alert-accent-ink")).toBe("var(--alert-accent)"); + }); + + it("gives neutral an ink that is not the page", () => { + const neutral = declarations(ruleBody(ALERT_CSS, ".alert--flavor-neutral")); + expect(neutral.get("--alert-accent-ink")).toBe("var(--color-base-content)"); + }); + + it("writes plain in ink rather than in the accent", () => { + const plain = declarations(ruleBody(ALERT_CSS, ".alert--plain")); + expect(plain.get("--alert-fg")).toBe("var(--alert-accent-ink)"); + }); +}); + +describe("no flavor leaves its ink pointing at the page", () => { + for (const flavor of FLAVORS) { + it(`resolves ${flavor} ink to something drawn on the page`, () => { + const rule = declarations(ruleBody(BUTTON_CSS, `.button--flavor-${flavor}`)); + const ink = rule.get("--button-accent-ink") ?? rule.get("--button-accent"); + expect(ink, `${flavor} sets neither an accent nor an ink`).toBeDefined(); + + for (const token of references(ink as string)) { + for (const [theme, css] of [ + ["light", LIGHT_CSS], + ["dark", DARK_CSS], + ] as const) { + const start = resolveThemeToken(css, token); + expect( + SURFACE_TOKENS.has(token) || SURFACE_TOKENS.has(start), + `${flavor} ink is the ${theme} page colour ${token}`, + ).toBeFalse(); + } + } + }); + } +}); From a5965cba06b1e3c862c13af3cda9b2b0f4ab8792 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:30:10 +0700 Subject: [PATCH 09/24] fix(popover): give the dialog the name written on Popover.Dialog `role="dialog"` is on `Popover.Content`, because the content is the portalled overlay and `Popover.Dialog` is an optional panel inside it. A reader naming a dialog writes `aria-label` on the part called Dialog, so the name landed on a generic inside the dialog and named nothing anything addresses. Reported from two sites; every popover dialog in the fleet was anonymous. The name travels up to the role rather than the role travelling down to the name: moving `role="dialog"` onto `Popover.Dialog` would leave a popover written without one with no dialog, and would make two dialogs of a popover written with two panels. The second half is the one that is easy to get wrong twice. The content already fell back to `aria-labelledby={triggerId}`, and `aria-labelledby` outranks `aria-label` in the name calculation, so forwarding the label while leaving that fallback in place would have kept the dialog named after the button that opened it with the author's own name ignored. A supplied name replaces the fallback. The rule is `resolvePopoverDialogName`, a pure function, so the ranking is asserted rather than inferred from the JSX. Consumers: nothing to change. A popover dialog that carried an `aria-label` starts being named by it; one that did not keeps the trigger as its name. --- src/components/popover/Popover.a11y.ts | 78 +++++++++ src/components/popover/Popover.layout.tsx | 73 +++++++- .../popover/Popover.dialog-name.test.ts | 157 ++++++++++++++++++ 3 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 src/components/popover/Popover.a11y.ts create mode 100644 tests/components/popover/Popover.dialog-name.test.ts diff --git a/src/components/popover/Popover.a11y.ts b/src/components/popover/Popover.a11y.ts new file mode 100644 index 00000000..83d11efc --- /dev/null +++ b/src/components/popover/Popover.a11y.ts @@ -0,0 +1,78 @@ +/** + * Which name reaches the node that carries `role="dialog"`. + * + * `role="dialog"` is on `Popover.Content`, because the content is the + * portalled overlay and `Popover.Dialog` is an optional panel inside it. A + * reader naming a dialog writes `aria-label` on `Popover.Dialog`, which is the + * part called Dialog, so the name landed on a generic inside the dialog and + * named nothing at all. Measured on two sites: every popover dialog in the + * fleet was anonymous. + * + * The name travels up to the role rather than the role travelling down to the + * name. Moving `role="dialog"` onto `Popover.Dialog` would leave a popover + * written without one with no dialog, and would make two dialogs of a popover + * written with two panels. + * + * `aria-labelledby` outranks `aria-label` in the accessible name calculation, + * so a supplied name has to *replace* the fallback reference to the trigger + * rather than sit beside it. Leaving both would keep the dialog named after + * the button that opened it while the author's own name was ignored, which is + * the same silent failure in a new place. + */ +export type PopoverDialogNameInput = { + /** `aria-label` written on `Popover.Content` itself. Outranks everything. */ + contentLabel?: string; + /** `aria-labelledby` written on `Popover.Content` itself. */ + contentLabelledBy?: string; + /** `aria-label` written on a `Popover.Dialog` inside the content. */ + dialogLabel?: string; + /** `aria-labelledby` written on a `Popover.Dialog` inside the content. */ + dialogLabelledBy?: string; + /** The trigger's id, used only when nothing else names the dialog. */ + triggerId?: string; +}; + +export type PopoverDialogName = { + "aria-label": string | undefined; + "aria-labelledby": string | undefined; +}; + +/** + * An ARIA attribute value as a name, or nothing. + * + * Solid types every `aria-*` attribute as `string | RemoveAttribute`, where the + * second member is `false` and means "do not emit this". A name is only ever + * the first, and the second has to become `undefined` before it can be + * compared, stored or handed to another element. + */ +export const asAriaName = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; + +const firstNonEmpty = (...values: (string | undefined)[]) => + values.find((value) => value !== undefined && value !== ""); + +export const resolvePopoverDialogName = ( + input: PopoverDialogNameInput, +): PopoverDialogName => { + const labelledBy = firstNonEmpty( + input.contentLabelledBy, + input.dialogLabelledBy, + ); + const label = firstNonEmpty(input.contentLabel, input.dialogLabel); + + // A reference wins outright: it is the stronger of the two and the author + // wrote it on purpose. + if (labelledBy !== undefined) { + return { "aria-label": undefined, "aria-labelledby": labelledBy }; + } + + // A name replaces the trigger fallback rather than competing with it. + if (label !== undefined) { + return { "aria-label": label, "aria-labelledby": undefined }; + } + + return { + "aria-label": undefined, + "aria-labelledby": firstNonEmpty(input.triggerId), + }; +}; diff --git a/src/components/popover/Popover.layout.tsx b/src/components/popover/Popover.layout.tsx index 22de4052..1608ab6f 100644 --- a/src/components/popover/Popover.layout.tsx +++ b/src/components/popover/Popover.layout.tsx @@ -22,6 +22,7 @@ import { type OverlayPlacement, } from "../_shared/overlayPosition"; import type { Material, UIBaseProps } from "../vocabulary"; +import { asAriaName, resolvePopoverDialogName } from "./Popover.a11y"; import { CLASSES, componentRecipe } from "./Popover.recipe"; export type PopoverPlacement = OverlayPlacement; @@ -46,6 +47,24 @@ type PopoverContextValue = { contentId: () => string; offset: () => number; onInteractOutside?: (event: Event) => void; + /* + * What `Popover.Dialog` was told to call the dialog. + * + * `role="dialog"` is on `Popover.Content`, because the content is the + * portalled overlay and `Popover.Dialog` is an optional panel inside it. So + * an `aria-label` written on `Popover.Dialog`, which is where a reader + * expects to name a dialog, landed on a generic inside the dialog and named + * nothing. Every popover dialog in the fleet was anonymous for that reason. + * + * The label travels up rather than the role travelling down: moving + * `role="dialog"` to `Popover.Dialog` would leave a popover written without + * one with no dialog at all, and would make two dialogs of a popover written + * with two panels. + */ + dialogLabel: () => string | undefined; + setDialogLabel: (next: string | undefined) => void; + dialogLabelledBy: () => string | undefined; + setDialogLabelledBy: (next: string | undefined) => void; }; const PopoverContext = createContext(null); @@ -106,6 +125,10 @@ const PopoverRoot: Layout = () => { ); const [resolvedPlacement, setResolvedPlacement] = createSignal(props.placement ?? "bottom"); + const [dialogLabel, setDialogLabel] = createSignal(); + const [dialogLabelledBy, setDialogLabelledBy] = createSignal< + string | undefined + >(); const isControlled = createMemo(() => props.open !== undefined); const isOpen = createMemo(() => @@ -209,6 +232,10 @@ const PopoverRoot: Layout = () => { contentId, offset, onInteractOutside: props.onInteractOutside, + dialogLabel, + setDialogLabel, + dialogLabelledBy, + setDialogLabelledBy, }; return ( @@ -327,6 +354,15 @@ const PopoverContent: Layout< ctx.setPlacement(overlayPosition.placement()); }); + const dialogName = () => + resolvePopoverDialogName({ + contentLabel: asAriaName(props["aria-label"]), + contentLabelledBy: asAriaName(props["aria-labelledby"]), + dialogLabel: ctx.dialogLabel(), + dialogLabelledBy: ctx.dialogLabelledBy(), + triggerId: ctx.triggerRef() ? ctx.triggerId() : undefined, + }); + const style = () => { const overlayStyle = overlayPosition.style(); @@ -363,10 +399,8 @@ const PopoverContent: Layout< data-placement={ctx.placement()} data-theme={props.dataTheme} style={style()} - aria-labelledby={ - props["aria-labelledby"] ?? - (ctx.triggerRef() ? ctx.triggerId() : undefined) - } + aria-label={dialogName()["aria-label"]} + aria-labelledby={dialogName()["aria-labelledby"]} aria-hidden={ctx.isOpen() ? "false" : "true"} > {props.children} @@ -385,7 +419,36 @@ const PopoverDialog: Layout< typeof componentRecipe, PopoverDialogProps > = () => { - const others = omit(props, "children", "class", "dataTheme", "style"); + const others = omit( + props, + "children", + "class", + "dataTheme", + "style", + "aria-label", + "aria-labelledby", + ); + + const ctx = usePopoverContext(); + + /* + * The name is handed to the node that carries `role="dialog"` rather than + * written here, and is withdrawn when this panel unmounts so a popover + * reopened without one is not still wearing the last one. + * + * Both attributes are removed from the passthrough above: repeating them on + * this generic would name an element nothing addresses, and would make a + * screen reader announce the name twice inside the dialog it already names. + */ + createTrackedEffect(() => { + ctx.setDialogLabel(asAriaName(props["aria-label"])); + return () => ctx.setDialogLabel(undefined); + }); + + createTrackedEffect(() => { + ctx.setDialogLabelledBy(asAriaName(props["aria-labelledby"])); + return () => ctx.setDialogLabelledBy(undefined); + }); return (
{ + it("names the dialog from a label written on Popover.Dialog", () => { + expect( + resolvePopoverDialogName({ + dialogLabel: "Filters", + triggerId: "popover-trigger-a1b2c3", + }), + ).toEqual({ "aria-label": "Filters", "aria-labelledby": undefined }); + }); + + it("drops the trigger fallback rather than letting it outrank the name", () => { + const name = resolvePopoverDialogName({ + dialogLabel: "Filters", + triggerId: "popover-trigger-a1b2c3", + }); + expect(name["aria-labelledby"]).toBeUndefined(); + }); + + it("falls back to the trigger when nothing names the dialog", () => { + expect( + resolvePopoverDialogName({ triggerId: "popover-trigger-a1b2c3" }), + ).toEqual({ + "aria-label": undefined, + "aria-labelledby": "popover-trigger-a1b2c3", + }); + }); + + it("leaves the dialog unnamed when there is no trigger yet", () => { + expect(resolvePopoverDialogName({})).toEqual({ + "aria-label": undefined, + "aria-labelledby": undefined, + }); + }); + + it("prefers a reference to a name, because the calculation does", () => { + expect( + resolvePopoverDialogName({ + dialogLabel: "Filters", + dialogLabelledBy: "filters-heading", + triggerId: "popover-trigger-a1b2c3", + }), + ).toEqual({ + "aria-label": undefined, + "aria-labelledby": "filters-heading", + }); + }); + + it("lets a name on the content itself win over one on the dialog", () => { + expect( + resolvePopoverDialogName({ + contentLabel: "Content wins", + dialogLabel: "Dialog loses", + })["aria-label"], + ).toBe("Content wins"); + }); + + it("treats an empty name as no name", () => { + expect( + resolvePopoverDialogName({ + dialogLabel: "", + triggerId: "popover-trigger-a1b2c3", + })["aria-labelledby"], + ).toBe("popover-trigger-a1b2c3"); + }); + + /* + * Solid types every aria attribute as `string | RemoveAttribute`, and the + * second member is `false`. Stored unnormalised it would become the string + * "false" on the dialog, which is a name. + */ + it("reads a removal request as no name at all", () => { + expect(asAriaName(false)).toBeUndefined(); + expect(asAriaName(undefined)).toBeUndefined(); + expect(asAriaName("Filters")).toBe("Filters"); + }); +}); + +/** + * The wiring, asserted separately from the rule. + * + * The rule above is a pure function and cannot tell whether the component + * calls it, nor whether `Popover.Dialog` still writes the attribute on its own + * generic. Both are what made the original defect invisible to review. + */ +describe("Popover.Dialog hands its name up instead of wearing it", () => { + const SOURCE = readFileSync( + join( + import.meta.dir, + "../../../src/components/popover/Popover.layout.tsx", + ), + "utf8", + ); + + const dialogBody = SOURCE.slice( + SOURCE.indexOf("const PopoverDialog:"), + SOURCE.indexOf("export type PopoverArrowProps"), + ); + + const contentBody = SOURCE.slice( + SOURCE.indexOf("const PopoverContent:"), + SOURCE.indexOf("export type PopoverDialogProps"), + ); + + it("keeps the name off the inner generic", () => { + expect(dialogBody).toContain('"aria-label"'); + expect(dialogBody).toContain('"aria-labelledby"'); + // Omitted from the passthrough, so the attribute cannot reach the div. + const omitted = dialogBody.slice( + dialogBody.indexOf("omit("), + dialogBody.indexOf(");", dialogBody.indexOf("omit(")), + ); + expect(omitted).toContain('"aria-label"'); + expect(omitted).toContain('"aria-labelledby"'); + }); + + it("reports the name into the popover context", () => { + expect(dialogBody).toContain("ctx.setDialogLabel("); + expect(dialogBody).toContain("ctx.setDialogLabelledBy("); + }); + + it("withdraws the name when the panel goes away", () => { + expect(dialogBody).toContain("ctx.setDialogLabel(undefined)"); + expect(dialogBody).toContain("ctx.setDialogLabelledBy(undefined)"); + }); + + it("applies the resolved name to the node that carries the role", () => { + expect(contentBody).toContain('role="dialog"'); + expect(contentBody).toContain("resolvePopoverDialogName("); + expect(contentBody).toContain('aria-label={dialogName()["aria-label"]}'); + expect(contentBody).toContain( + 'aria-labelledby={dialogName()["aria-labelledby"]}', + ); + }); +}); From e39bdcdc3357b987aaa7c1c9b1b809d756ba2a6d Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:31:50 +0700 Subject: [PATCH 10/24] feat(icon): let a meaningful icon carry a name `aria-hidden="true"` was written straight into the markup with nothing conditioning it, and there was no prop to condition it with. The default is right: nearly every icon in the fleet sits beside text that already says what it means, and announcing it repeats it. But it was the only behaviour there was, and `aria-hidden` removes an element from the tree whatever else it carries, so a call site that passed `aria-label` through got an icon wearing a name nothing could read. A status glyph in a table cell, a lone mark in a square button and a trend arrow beside a bare number were all unreachable. `label` swaps `aria-hidden` for `role="img"` and that name. One prop rather than an escape hatch per attribute, because an icon that is announced needs a role and a name together or neither, and two props can disagree. Consumers: nothing to change. Every existing call site keeps the hidden default. --- docs/api-contract.md | 1 + docs/ui-usage.md | 16 +++++- src/components/icon/Icon.layout.tsx | 28 +++++++++- tests/components/icon/Icon.label.test.ts | 65 ++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/components/icon/Icon.label.test.ts diff --git a/docs/api-contract.md b/docs/api-contract.md index 9c891da3..feec9bbb 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -1062,6 +1062,7 @@ i18n: I18nStore ```ts flavor?: Flavor height?: number +label?: string src?: string | JSX.Element width?: number ``` diff --git a/docs/ui-usage.md b/docs/ui-usage.md index bae4b7f4..0cafeb98 100644 --- a/docs/ui-usage.md +++ b/docs/ui-usage.md @@ -481,7 +481,7 @@ toast.success("Saved"); toast.danger("Failed"); toast.promise(p, {loading, succe ## Icons -`Icon` takes one prop, `src`, and which source it is, is the type: a string is a +`Icon`'s main prop is `src`, and which source it is, is the type: a string is a preload token (`"lucide--copy"`, or the wrapped `"icon-[lucide--copy]"`), an element is inline SVG you own. @@ -490,6 +490,20 @@ element is inline SVG you own. …} /> ``` +An icon is hidden from the accessibility tree by default, which is right for +the common case: it sits next to text that already says what it means, and +announcing it says the same thing twice. When the icon *is* the label, name it +with `label`, which swaps `aria-hidden` for `role="img"` and that name. + +```tsx + + +``` + +Reach for `label` only when nothing else names the thing. A square `Button` +with its own `aria-label` is already named, so labelling the icon inside it +makes the control announce twice. + **The library ships no glyphs.** Your app generates the CSS that resolves a token, with `@plugin "@iconify/tailwind4"` under Tailwind v4 or `@pathscale/rsbuild-plugin-iconify` under rsbuild, scanning your source rather diff --git a/src/components/icon/Icon.layout.tsx b/src/components/icon/Icon.layout.tsx index 0af46919..68bd1ca3 100644 --- a/src/components/icon/Icon.layout.tsx +++ b/src/components/icon/Icon.layout.tsx @@ -26,6 +26,21 @@ export type IconProps = UIBaseProps & { width?: number; height?: number; flavor?: Flavor; + /** + * What this icon means, for an icon that carries meaning on its own. + * + * Most icons sit beside a label that already says it, and repeating it makes + * a reader hear the same thing twice; those stay hidden, which is the + * default and stays the default. The ones that need this are the icons that + * *are* the label: a status glyph in a table cell, a lone mark in a square + * button, a trend arrow next to a bare number. + * + * Setting it swaps `aria-hidden="true"` for `role="img"` and this name. The + * default was right and there was simply no way off it: `aria-hidden` was + * written straight into the markup, so it survived any `aria-label` a call + * site passed through and the icon stayed out of the tree regardless. + */ + label?: string; }; /* ------------------------------------------------------------------------------------------------- @@ -40,8 +55,17 @@ export type IconProps = UIBaseProps & { * * Square by default at 24px, with both dimensions still separate because some * sets ship rectangular glyphs and forcing them square crops them. + * + * Hidden from the accessibility tree unless `label` says otherwise. The default + * is right for nearly every icon in the fleet -- decoration beside text that + * already says it -- and it was also the only behaviour there was: `aria-hidden` + * was written into the markup, so a call site that passed `aria-label` got an + * element that carried both and stayed out of the tree anyway. `label` is the + * way off it, and it is one prop rather than an escape hatch per attribute + * because an icon that is announced needs a role and a name together or neither. * -----------------------------------------------------------------------------------------------*/ export const IconLayout: Layout = () => ( + // biome-ignore lint/a11y/useAriaPropsSupportedByRole: role and name are decided by one prop, so both are present or neither is; the rule reads a static role only. = () => ( }} data-flavor={local.flavor ?? "inherit"} data-source={typeof local.src === "string" ? "preload" : "svg"} - aria-hidden="true" + role={local.label ? "img" : undefined} + aria-label={local.label} + aria-hidden={local.label ? undefined : "true"} > { + it("declares the prop, so the escape hatch is part of the type", () => { + expect(CODE).toMatch(/\blabel\?:\s*string;/); + }); + + it("never hides an icon unconditionally", () => { + expect(CODE).not.toContain('aria-hidden="true"'); + expect(CODE).toContain('aria-hidden={local.label ? undefined : "true"}'); + }); + + it("gives a named icon a role and a name together", () => { + expect(CODE).toContain('role={local.label ? "img" : undefined}'); + expect(CODE).toContain("aria-label={local.label}"); + }); + + /* + * Both halves or neither. `role="img"` with no name is an unnamed image, and + * `aria-label` on a `span` with no role names nothing: the two conditions + * have to read the same prop. + */ + it("conditions the role and the name on the same prop", () => { + const role = CODE.match(/role=\{local\.(\w+)\s*\?/)?.[1]; + const hidden = CODE.match(/aria-hidden=\{local\.(\w+)\s*\?/)?.[1]; + expect(role).toBe("label"); + expect(hidden).toBe("label"); + }); +}); From 0cfea249c2d2dc51227911b9ec9fe8560142257b Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:35:20 +0700 Subject: [PATCH 11/24] feat(text): let a title be a heading `Text` rendered a `span` and nothing else, so `family="heading"` set the text in the heading face and left the document flat. Sites read the two as one thing, and the result was pages -- landing pages among them -- with no heading of any role anywhere on them: a reader using headings to move through a page found nothing to move between. Found on multiple sites independently, which is what makes it this component's problem rather than each site's. `family` is a typeface and `as` is the document. Two axes because they are two questions: a caption can be set in the display face without being a heading, and an h2 can be set in the body face. Why `as` rather than `Heading` components. A separate heading component would be a second way to write a title, and every existing `family="heading"` call site in the fleet would still be wrong -- each would need finding and rewriting onto a different component with a different prop set, where `as` fixes them with a one-token edit on the component they already use. It also cannot drift: two components mean two places where size, weight, tracking and leading are decided, and the axis that picks the face now sits next to the axis that picks the element in the same props table, which is the clearest statement that they are different questions. The list is closed rather than `keyof JSX.IntrinsicElements`. The prop exists to make a title a heading, and an open list makes that one option among two hundred, most of which are wrong for a run of text. Consumers: nothing breaks. `as` defaults to `span`, so every existing call site renders exactly what it rendered before. What consumers must do is add `as` to their titles; a page with a title and no `as="h1"` on it still has no heading. --- docs/api-contract.md | 1 + docs/ui-usage.md | 19 ++++++ src/components/text/Text.layout.tsx | 46 ++++++++++++- src/components/text/index.ts | 1 + src/index.ts | 1 + tests/components/text/Text.as.test.ts | 96 +++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 tests/components/text/Text.as.test.ts diff --git a/docs/api-contract.md b/docs/api-contract.md index feec9bbb..21d8e631 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -1741,6 +1741,7 @@ variant?: TabsVariant ### Text ```ts +as?: TextAs children?: JSX.Element family?: TextFamily leading?: TextLeading diff --git a/docs/ui-usage.md b/docs/ui-usage.md index 0cafeb98..12e73c69 100644 --- a/docs/ui-usage.md +++ b/docs/ui-usage.md @@ -178,6 +178,25 @@ The available roles are `body`, `heading`, `display`, and `mono`. They resolve t `--font-body`, `--font-heading`, `--font-display`, and `--font-mono`, with `--font-sans` as the shared fallback. This works with PathScale Fonts and application-owned font faces. +**`family="heading"` is a typeface, not a heading.** It says which face to set +the text in and nothing about the document. Say which element the text *is* +with `as`: + +```tsx +Pricing +What you get +Every plan includes the same engine. +``` + +`as` defaults to `span`, which is right for a run of text inside a sentence and +is what `Text` has always rendered. It accepts `span`, `p`, `div`, `h1` through +`h6`, `strong` and `em`. + +Reach for it on every title. Sites read `family="heading"` as making a heading, +and the result was pages, landing pages among them, with no heading of any role +anywhere on them: a reader using headings to move through the page found +nothing to move between. If a page has a title, that title is an `h1`. + ## Component inventory (by family) - **Layout/primitives**: Flex, Grid, Join, Card, Separator, ScrollArea, Skeleton, Empty, Footer, Header, Navbar, Toolbar, Dock diff --git a/src/components/text/Text.layout.tsx b/src/components/text/Text.layout.tsx index b81d88e6..90a19162 100644 --- a/src/components/text/Text.layout.tsx +++ b/src/components/text/Text.layout.tsx @@ -1,5 +1,5 @@ import "./Text.css"; -import type { JSX } from "@solidjs/web"; +import { Dynamic, type JSX } from "@solidjs/web"; import {omit, type Component} from "solid-js"; import type { UIBaseProps } from "../vocabulary"; @@ -14,6 +14,28 @@ export type TextTracking = "normal" | "wide"; export type TextLeading = "normal" | "none"; export type TextFamily = "body" | "heading" | "display" | "mono"; +/** + * The elements `Text` will render as. + * + * Deliberately a closed list rather than `keyof JSX.IntrinsicElements`. The + * point of the prop is to let a title *be* a heading, and an open list makes + * that one option among two hundred, most of which are wrong for a run of + * text. Six heading levels, the three neutral containers, and the two + * inline emphases that carry meaning of their own. + */ +export type TextAs = + | "span" + | "p" + | "div" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "strong" + | "em"; + export type TextRootProps = Omit, "color"> & UIBaseProps & { size?: TextSize; @@ -23,6 +45,21 @@ export type TextRootProps = Omit, "color"> & tracking?: TextTracking; leading?: TextLeading; family?: TextFamily; + /** + * The element this text is. + * + * `span` by default, which is what it has always been and what a run of + * text inside a sentence should stay. Reach for this whenever the text is + * structural: `as="h1"` for a page title, `as="h2"` for a section, `as="p"` + * for a paragraph. + * + * `family="heading"` is a *typeface*: it says which face to set the text + * in, and it has never said anything about the document. Sites read the + * two as one thing and styled titles that were not headings, so entire + * pages, landing pages included, had no heading of any role on them and no + * way for a reader to move between sections. This is the axis that says so. + */ + as?: TextAs; children?: JSX.Element; }; @@ -40,13 +77,16 @@ const TextRoot: Layout = () => { "tracking", "leading", "family", + "as", ); const size = () => props.size ?? "base"; const variant = () => props.variant ?? "default"; + const tag = () => props.as ?? "span"; return ( - = () => { style={props.style} > {props.children} - + ); }; diff --git a/src/components/text/index.ts b/src/components/text/index.ts index 2e99fd57..560f491f 100644 --- a/src/components/text/index.ts +++ b/src/components/text/index.ts @@ -1,6 +1,7 @@ export { default, Text, + type TextAs, type TextFamily, type TextLeading, type TextProps, diff --git a/src/index.ts b/src/index.ts index dfe1baa3..86ed9d8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -475,6 +475,7 @@ export type { } from "./components/tabs"; export { default as Tabs } from "./components/tabs"; export type { + TextAs, TextFamily, TextLeading, TextProps, diff --git a/tests/components/text/Text.as.test.ts b/tests/components/text/Text.as.test.ts new file mode 100644 index 00000000..6ecffff3 --- /dev/null +++ b/tests/components/text/Text.as.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * A title styled as a heading has to be able to *be* one. + * + * `Text` rendered a `span` and nothing else, so `family="heading"` set the + * text in the heading face and left the document flat. Sites read the two as + * one thing, and the result was pages -- landing pages included -- with no + * heading of any role anywhere on them, and no way for a reader to move + * between sections. Found on multiple sites independently, which is what makes + * it the component's problem rather than each site's. + * + * `family` is a typeface and `as` is the document. Two axes, because they are + * two questions: a caption can be set in the display face without being a + * heading, and an `h2` can be set in the body face. + * + * ## Why `as`, and not `Heading` components + * + * A `Text.Heading` or a separate `` would be a second way + * to write a title, and every one of the fleet's existing `family="heading"` + * call sites would still be wrong -- each would need finding, and rewriting to + * a different component with a different prop set. `as` fixes exactly those + * call sites with a one-token edit, on the component they already use. + * + * It also cannot drift. Two components mean two places where size, weight, + * tracking and leading are decided, and they diverge; one component with two + * axes has one place. The axis that decides the face and the axis that decides + * the element sit next to each other in the same props table, which is the + * clearest statement that they are different questions. + * + * The list is closed rather than `keyof JSX.IntrinsicElements`. The prop + * exists to make a title a heading, and an open list makes that one option + * among two hundred, most of which are wrong for a run of text. + */ +const SOURCE = readFileSync( + join(import.meta.dir, "../../../src/components/text/Text.layout.tsx"), + "utf8", +); + +const CODE = SOURCE.replace(/\/\*[\s\S]*?\*\//g, ""); + +const BARREL = readFileSync( + join(import.meta.dir, "../../../src/components/text/index.ts"), + "utf8", +); + +const ROOT = readFileSync( + join(import.meta.dir, "../../../src/index.ts"), + "utf8", +); + +describe("Text.as", () => { + it("offers every heading level", () => { + const union = CODE.slice( + CODE.indexOf("export type TextAs ="), + CODE.indexOf(";", CODE.indexOf("export type TextAs =")), + ); + for (const level of ["h1", "h2", "h3", "h4", "h5", "h6"]) { + expect(union, `TextAs cannot render ${level}`).toContain(`"${level}"`); + } + }); + + it("keeps the list closed", () => { + expect(CODE).not.toContain("as?: keyof JSX.IntrinsicElements"); + expect(CODE).toContain("as?: TextAs;"); + }); + + it("stays a span when nothing asks otherwise", () => { + expect(CODE).toContain('const tag = () => props.as ?? "span";'); + }); + + it("renders the element the prop names", () => { + expect(CODE).toContain("component={tag()}"); + expect(CODE).not.toMatch(/ { + const omitted = CODE.slice( + CODE.indexOf("omit("), + CODE.indexOf(");", CODE.indexOf("omit(")), + ); + expect(omitted).toContain('"as"'); + }); + + it("still carries the typeface as its own axis", () => { + expect(CODE).toContain("data-family={props.family}"); + expect(CODE).toContain("family?: TextFamily;"); + }); + + it("exports the type a consumer needs to name the prop", () => { + expect(BARREL).toContain("type TextAs"); + expect(ROOT).toContain("TextAs"); + }); +}); From 363ae3db1df28f9d1f970bb9a525798fe1414b58 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:40:49 +0700 Subject: [PATCH 12/24] fix(card): stop putting a button inside a link `isInteractive` gave every card `role="button"` and `tabindex="0"` with no way off either, so the ordinary way to make a whole card navigate, ``, produced a button inside a link with the same name and the same box. Measured on one site as every card on it. Nested interactive content is invalid HTML, a reader hears the card twice, and a press by coordinate lands on whichever of the two is on top. `isInteractive` was doing two jobs. It is how a card asks for hover and press *affordance*, and it was also read as "announce this as a button". Only the first survives inside a link, and it is the only one such a card wants. A card is a button when it looks pressable and has something to press, so a card inside a link, which carries no handler because the anchor is what navigates, is fixed where it stands with nothing edited. Two more ways out, both of which were missing or broken. `href` renders the card as a real anchor, so a card that navigates *is* the link and there is nothing left to nest it in, exactly as Button already works. And an explicit `role` was accepted and then half-ignored: `role="presentation"` replaced the role and left `tabindex="0"` behind, so the component's only opt-out produced an element out of the accessibility tree that still stopped the keyboard. The decision is `cardSemantics`, a pure function, so it is asserted rather than read out of JSX. Both rendered forms stay literal elements rather than one Dynamic: Button learnt that the expensive way, where a Dynamic string element painted correctly under Blitz and dropped a nested consumer's event binding, which on a card full of buttons is the whole point of the card. Consumers: a card inside a link needs nothing. A card that carried its own onClick is unchanged. A card whose click was handled by an ancestor rather than by the card keeps the affordance and loses the role and the tab stop -- move the handler onto the Card, or give the Card the href and drop the wrapper. --- docs/api-contract.md | 6 + docs/ui-usage.md | 33 +++- src/components/card/Card.interactions.ts | 125 ++++++++++++++ src/components/card/Card.layout.tsx | 90 ++++++++-- tests/components/card/Card.semantics.test.ts | 173 +++++++++++++++++++ 5 files changed, 411 insertions(+), 16 deletions(-) create mode 100644 src/components/card/Card.interactions.ts create mode 100644 tests/components/card/Card.semantics.test.ts diff --git a/docs/api-contract.md b/docs/api-contract.md index 21d8e631..85003ca5 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -253,11 +253,14 @@ elevation?: CardElevation flavor?: Flavor footer?: JSX.Element header?: JSX.Element +href?: string isInteractive?: boolean material?: Material padding?: Space radius?: Radius +rel?: string state?: CardState +target?: JSX.AnchorHTMLAttributes["target"] variant?: Variant ``` @@ -287,11 +290,14 @@ elevation?: CardElevation flavor?: Flavor footer?: JSX.Element header?: JSX.Element +href?: string isInteractive?: boolean material?: Material padding?: Space radius?: Radius +rel?: string state?: CardState +target?: JSX.AnchorHTMLAttributes["target"] variant?: Variant ``` diff --git a/docs/ui-usage.md b/docs/ui-usage.md index 12e73c69..f6c39c3c 100644 --- a/docs/ui-usage.md +++ b/docs/ui-usage.md @@ -125,14 +125,41 @@ to a more opaque fill. common: every accepted value did nothing, every implemented value was a type error. Now `CardState`, which is what it renders. A card is not a form control; interactivity is `isInteractive`. +- **Behaviour change — an interactive `Card` is a button only when it has + something to press.** `isInteractive` gave every card `role="button"` and + `tabindex="0"`, so the ordinary way to make a whole card navigate, + ``, produced a `button` inside a `link` + with the same name and the same box: invalid HTML, announced twice, and a + press by coordinate landing on whichever was on top. Measured on one site as + every card on it. `isInteractive` now means what it looks like it means, the + hover and press affordance; the button role and the keyboard activation + arrive with an `onClick`. + + So a card inside a link is fixed where it stands, with nothing to edit. A + card that carried a handler is unchanged. What does change is a card whose + click was handled by an ancestor rather than by the card: it keeps the + affordance and loses the role and the tab stop, so move the handler onto the + `Card`. + + Better still, if the card navigates, give it the `href` and drop the wrapper: + + ```tsx + + ``` + + `Card` then renders a real anchor, so middle-click, open-in-new-tab and copy + link address work, and there is nothing left to nest it inside. `role` is + also honoured properly now: `role="presentation"` no longer leaves + `tabindex="0"` behind. - `Slider.onChange` reports continuous values. Optional `Slider.onChangeEnd` reports the final changed value once on pointer release, pointer cancellation, keyboard release, or blur fallback. Its visible `label` is also copied to the semantic slider's `aria-label`, because not every renderer resolves `aria-labelledby` across a visually hidden label. - `Collapsible.Content` retains closed content by default. Set `keepMounted={false}` to mount it only while expanded; the check is reactive, so it mounts and unmounts as the state changes. - `Popover` accepts `anchorRect` as a rectangle or rectangle accessor when content must be positioned without a trigger element. - Compound components: `Dialog.Trigger`, `Tabs.List`, `Select.Option`, etc. (`Object.assign` statics; also exported flat: `AccordionRoot`, `AlertTitle`, …). Parts are styleable/testable via `data-slot="..."` and state attrs (`data-open`, `data-selected`, `data-invalid`). - `Tabs` does not require `ResizeObserver`. When it is unavailable, selection and keyboard behavior remain active and the indicator is measured on selection, mount, and window resize. -- `Flex`, `Grid` and `Navbar` take a polymorphic `as`. Nothing else does; reach - for the component that renders the element you want rather than repointing one - that does not. +- `Flex`, `Grid`, `Navbar` and `Text` take a polymorphic `as`, and `Card` and + `Button` take an `href` that makes them anchors. Nothing else is polymorphic; + reach for the component that renders the element you want rather than + repointing one that does not. ```tsx diff --git a/src/components/card/Card.interactions.ts b/src/components/card/Card.interactions.ts new file mode 100644 index 00000000..592d4426 --- /dev/null +++ b/src/components/card/Card.interactions.ts @@ -0,0 +1,125 @@ +/** + * What an interactive card *is*, as one function. + * + * `isInteractive` gave a card `role="button"` and `tabindex="0"` with no way + * off either. That is right for a card that is the only activatable thing in + * its box, and wrong the moment a card is put inside a link, which is the + * ordinary way to make a whole card navigate. Measured on one site: every card + * on it was a `button` inside a `link`, same name, same box. Nested + * interactive content is invalid HTML, a reader hears the card twice, and a + * press by coordinate lands on whichever of the two is on top. + * + * Three things come out of this, and the first is what fixes the sites without + * anyone editing a call site. A card is announced as a button when it looks + * pressable *and has something to press*: `` + * hands the card no handler, because the anchor is what navigates, so the card + * stops claiming to be a button and the nesting goes away where it stands. + * `isInteractive` was doing two jobs at once and only one of them survives + * inside a link: it is how a card asks for hover and press affordance, which is + * exactly what a card inside a link wants, and all it wants. + * + * The second is `href`: a card that navigates should *be* the anchor rather + * than sit inside one, exactly as `Button` already does. The third is an + * explicit `role`, which was accepted and then half-ignored -- + * `role="presentation"` left `tabindex="0"` behind, so the escape hatch + * produced a presentational element that was still in the tab order and still + * announced as focusable. + * + * A `.layout.tsx` binds free identifiers to props, so the rule lives beside + * the markup rather than inside it, and the repository's tests are pure. + */ + +import type { JSX } from "@solidjs/web"; + +/* + * Solid types every attribute as `T | RemoveAttribute`, where the second member + * is `false` and means "do not emit this". Both halves arrive here and only the + * first can be reasoned about, so `given` collapses the other to `undefined` + * once, at the boundary. + */ +type RoleAttribute = JSX.HTMLAttributes["role"]; +type TabIndexAttribute = JSX.HTMLAttributes["tabindex"]; +type Role = Exclude; +type TabIndex = Exclude; + +export type CardSemanticsInput = { + href?: string | false; + isInteractive?: boolean; + /** + * Whether the card has an activation of its own. + * + * This is the half that fixes the fleet without anyone editing a call site. + * `` gives the card no handler: the anchor + * is what navigates. A card with nothing to activate is not a button, so it + * stops claiming to be one, and the nesting goes away where it is. + * + * `isInteractive` alone was read as "announce this as a button", and it + * cannot be: it is also how a card asks for hover and press *affordance*, + * which is exactly what a card inside a link wants and all it wants. + */ + hasActivation?: boolean; + /** An explicit `role` from the call site. Wins outright. */ + role?: RoleAttribute; + /** An explicit `tabindex` from the call site. Wins outright. */ + tabindex?: TabIndexAttribute; +}; + +export type CardSemantics = { + element: "a" | "div"; + role: Role | undefined; + tabindex: TabIndex | undefined; + /** + * Whether the card has to implement Enter and Space itself. + * + * Only a `div` wearing `role="button"` does. An anchor activates on Enter + * natively, and a presentational card activates on nothing. + */ + handlesKeyboardActivation: boolean; +}; + +const given = (value: T | false | undefined): T | undefined => + value === false || value === undefined ? undefined : value; + +export const cardSemantics = (input: CardSemanticsInput): CardSemantics => { + const role = given(input.role); + const tabindex = given(input.tabindex); + + /* + * An anchor is already a link, already focusable and already activated by + * Enter. Adding `role="button"` on top of it would replace the one thing a + * reader wants to know about it, and adding `tabindex` would say nothing it + * did not already say. + */ + if (typeof input.href === "string") { + return { + element: "a", + role, + tabindex, + handlesKeyboardActivation: false, + }; + } + + /* + * A button, but only when there is something to press. `isInteractive` says + * "look pressable"; a handler is what makes it pressable. A card that looks + * pressable because the link around it is, and announces itself as a button + * that does nothing, is the whole reported defect. + */ + const resolvedRole = + role ?? + (input.isInteractive && input.hasActivation ? "button" : undefined); + return { + element: "div", + role: resolvedRole, + /* + * Focusable exactly when it is a button, and otherwise only if the call + * site asked. Two cases were wrong before, in opposite directions: + * `role="presentation"` kept `tabindex="0"`, which is an element out of + * the accessibility tree that still stops the keyboard on the way past and + * made the component's only opt-out useless; and a card with no activation + * of its own was focusable while there was nothing to activate. + */ + tabindex: tabindex ?? (resolvedRole === "button" ? 0 : undefined), + handlesKeyboardActivation: resolvedRole === "button", + }; +}; diff --git a/src/components/card/Card.layout.tsx b/src/components/card/Card.layout.tsx index 50045cbf..7cb79013 100644 --- a/src/components/card/Card.layout.tsx +++ b/src/components/card/Card.layout.tsx @@ -4,6 +4,8 @@ import "./Card.css"; import {Show} from "solid-js"; import type { Flavor, Material, Radius, Space, UIBaseProps, Variant } from "../vocabulary"; import type { Layout } from "../../lib/layouts"; +import { buttonHref, buttonRel } from "../button/Button.interactions"; +import { cardSemantics } from "./Card.interactions"; import { card, cardBody, cardFooter, cardHeader } from "./Card.recipe"; /* ------------------------------------------------------------------------------------------------- @@ -48,6 +50,23 @@ export type CardProps = Omit, "children"> & radius?: Radius; /** Replaces isHoverable and isPressable, which had one call site each across 330. */ isInteractive?: boolean; + /** + * Renders the card as an anchor, and navigates. + * + * A whole card that navigates is the common shape, and the way it was + * written was ``, which put a `button` + * inside a `link` with the same name and the same box. That is invalid + * HTML, it announces the card twice, and a press by coordinate lands on + * whichever of the two happens to be on top. + * + * A card that navigates should be the anchor rather than sit inside one, + * which is what `Button` already does. Middle-click, right-click, + * open-in-new-tab and "copy link address" all work, which a `div` with a + * click handler takes away. + */ + href?: string; + target?: JSX.AnchorHTMLAttributes["target"]; + rel?: string; header?: JSX.Element; footer?: JSX.Element; children: JSX.Element; @@ -75,27 +94,33 @@ export const CardFooterLayout: Layout = () * Card.Footer remain for anything that needs to interleave. * * An interactive card gets a button role and keyboard activation, because a - * div that responds to click and nothing else is unreachable by keyboard. + * div that responds to click and nothing else is unreachable by keyboard -- + * unless it is already something activatable, which is what `href` and an + * explicit `role` are for. `cardSemantics` decides between the three, and it + * is a pure function so the decision is asserted rather than read out of JSX. * -----------------------------------------------------------------------------------------------*/ export const CardLayout: Layout = () => { + const semantics = () => + cardSemantics({ + href: local.href, + isInteractive: local.isInteractive, + // A handler is what makes a card pressable; `isInteractive` only makes it + // look it. A card inside a link has the second and not the first. + hasActivation: local.onClick != null, + role: local.role, + tabindex: local.tabindex, + }); + const handleKeyDown: JSX.EventHandlerUnion = (event) => { - if (!local.isInteractive) return; + if (!semantics().handlesKeyboardActivation) return; if (event.key !== "Enter" && event.key !== " ") return; if (event.target !== event.currentTarget) return; event.preventDefault(); event.currentTarget.click(); }; - return ( -
+ const body = () => ( + <> {local.header} @@ -105,7 +130,46 @@ export const CardLayout: Layout = () => { {local.footer} -
+ + ); + + /* + * Both forms are literal elements rather than one `Dynamic`. `Button` learnt + * this the expensive way: a Dynamic string element painted correctly under + * Blitz and dropped a nested consumer's event binding, which on a card full + * of buttons is the whole point of the card. + */ + return ( + + {body()} +
+ } + > + + {body()} + + ); }; diff --git a/tests/components/card/Card.semantics.test.ts b/tests/components/card/Card.semantics.test.ts new file mode 100644 index 00000000..7353112b --- /dev/null +++ b/tests/components/card/Card.semantics.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { cardSemantics } from "../../../src/components/card/Card.interactions"; + +/** + * An interactive card must not be a second interactive element. + * + * `isInteractive` gave the card `role="button"` and `tabindex="0"` and offered + * no way off either, so the ordinary way to make a whole card navigate -- + * wrapping it in a link -- produced a `button` inside a `link` with the same + * name and the same box. Measured on one site: every card on it. Nested + * interactive content is invalid HTML, a reader hears the card twice, and a + * press by coordinate lands on whichever of the two is on top. + * + * Three things come out of the rule, and the first is what fixes the sites + * with no call site edited: a card is a button when it looks pressable *and + * has something to press*. `` hands the card + * no handler, because the anchor is what navigates, so the card stops claiming + * to be a button and the nesting goes away where it stands. `isInteractive` + * was doing two jobs and only one of them survives inside a link: it is how a + * card asks for hover and press affordance, which is exactly what a card + * inside a link wants and all it wants. + * + * `href` is the second: a card that navigates should be the anchor. An + * explicit `role` is the third, and it was already accepted and then ignored + * where it mattered -- `role="presentation"` replaced the role and left + * `tabindex="0"` behind, so the only opt-out the component had produced an + * element that was out of the accessibility tree and still stopped the + * keyboard on the way past. + */ +describe("cardSemantics", () => { + it("keeps the plain card a plain div", () => { + expect(cardSemantics({})).toEqual({ + element: "div", + role: undefined, + tabindex: undefined, + handlesKeyboardActivation: false, + }); + }); + + it("makes a card with a handler reachable by keyboard", () => { + expect(cardSemantics({ isInteractive: true, hasActivation: true })).toEqual({ + element: "div", + role: "button", + tabindex: 0, + handlesKeyboardActivation: true, + }); + }); + + /* + * The reported shape, from the outside in. The card is inside a link and + * carries no handler of its own, so it announces nothing and takes no focus, + * and the link around it is the only interactive element in the box. + */ + it("does not announce a card that has nothing to press", () => { + expect(cardSemantics({ isInteractive: true })).toEqual({ + element: "div", + role: undefined, + tabindex: undefined, + handlesKeyboardActivation: false, + }); + }); + + it("becomes the link rather than sitting inside one", () => { + const semantics = cardSemantics({ href: "/pricing", isInteractive: true }); + expect(semantics.element).toBe("a"); + expect(semantics.role).toBeUndefined(); + expect(semantics.tabindex).toBeUndefined(); + }); + + it("leaves Enter to the anchor, which already handles it", () => { + expect( + cardSemantics({ href: "/pricing", isInteractive: true }) + .handlesKeyboardActivation, + ).toBeFalse(); + }); + + /* + * The regression this file exists for. Before the fix these two returned + * `tabindex: 0`, which is what made the escape hatch useless: the card was + * announced as nothing and was still in the tab order. + */ + for (const role of ["presentation", "none"] as const) { + it(`takes a ${role} card out of the tab order too`, () => { + const semantics = cardSemantics({ + isInteractive: true, + hasActivation: true, + role, + }); + expect(semantics.role).toBe(role); + expect(semantics.tabindex).toBeUndefined(); + expect(semantics.handlesKeyboardActivation).toBeFalse(); + }); + } + + it("honours a role the call site chose over the one it would pick", () => { + const semantics = cardSemantics({ + isInteractive: true, + hasActivation: true, + role: "listitem", + }); + expect(semantics.role).toBe("listitem"); + expect(semantics.handlesKeyboardActivation).toBeFalse(); + }); + + it("honours an explicit tabindex, including a negative one", () => { + expect( + cardSemantics({ isInteractive: true, hasActivation: true, tabindex: -1 }) + .tabindex, + ).toBe(-1); + }); + + /* + * Solid spells "remove this attribute" as `false`, and it arrives here as a + * prop like any other. Read as a value it would suppress the fallback and + * leave an interactive card unfocusable. + */ + it("reads a removal request as absence, not as a choice", () => { + expect( + cardSemantics({ + isInteractive: true, + hasActivation: true, + role: false, + tabindex: false, + }), + ).toEqual({ + element: "div", + role: "button", + tabindex: 0, + handlesKeyboardActivation: true, + }); + }); +}); + +describe("Card renders what the rule decided", () => { + const SOURCE = readFileSync( + join(import.meta.dir, "../../../src/components/card/Card.layout.tsx"), + "utf8", + ); + const CODE = SOURCE.replace(/\/\*[\s\S]*?\*\//g, ""); + + it("asks the rule instead of restating it", () => { + expect(CODE).toContain("cardSemantics({"); + expect(CODE).not.toContain('local.isInteractive ? "button" : undefined'); + expect(CODE).not.toContain("local.isInteractive ? 0 : undefined"); + }); + + it("renders an anchor when the rule says anchor", () => { + expect(CODE).toContain('semantics().element === "a"'); + expect(CODE).toContain("href={buttonHref(local.href, false)}"); + }); + + /* + * Literal elements rather than one Dynamic. Button learnt this the expensive + * way: a Dynamic string element painted correctly under Blitz and dropped a + * nested consumer's event binding, which on a card full of buttons is the + * whole point of the card. + */ + it("keeps both forms literal", () => { + expect(CODE).not.toContain("Dynamic"); + expect(CODE).toContain(" { + expect(CODE).toContain("semantics().handlesKeyboardActivation"); + }); + + it("reports whether the card has an activation of its own", () => { + expect(CODE).toContain("hasActivation: local.onClick != null"); + }); +}); From 79c6054b92c7807016ac4d81dc0a9dcd5896d5b0 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:44:08 +0700 Subject: [PATCH 13/24] fix(popover,drawer): let a trigger be the control it was given Both triggers render a `button`. That is right when the trigger is a word or a glyph, and wrong the moment a call site hands one a control, which is how both are commonly written. `` emitted an anonymous twin button wrapping the named one at identical coordinates carrying `slot=popover-trigger`, found on three separate sites; `` put `slot=drawer-trigger` around `slot=button` at an identical box with an identical name, so every trigger was announced twice. Nested interactive content is invalid HTML either way, and a press by coordinate lands on the outer element rather than on the control that was written. `as` makes the control be the trigger, so one element carries the name, the box and the wiring: Filters Only the delegate branch is a `Dynamic`. Button already found that a Dynamic string element paints correctly under Blitz and drops a nested consumer's event binding, leaving an enabled control that acknowledges activation without running its handler; on the element that opens a popover that is the whole component. So the ordinary path keeps the literal ` {/* two buttons */} +``` + +produces a second, anonymous button wrapping the named one at identical +coordinates. Nested interactive content is invalid HTML, the control is +announced twice, and a press by coordinate lands on the outer wrapper rather +than on the button you wrote. Measured on three sites for `Popover` and on +`Drawer` besides. + +Make the control *be* the trigger with `as`: + +```tsx +Filters +Menu +``` + +One element carries the name, the box and the wiring. Everything else you pass +goes to the delegate, so `flavor`, `size` and the rest work as they always do. +Two things do not travel: the trigger's own class, which is a button reset that +a real control must not be given, and `data-slot="popover-trigger"` / +`data-slot="drawer-trigger"`, because the delegate's own recipe owns that +attribute. Select on the delegate's slot, or on `[aria-haspopup="dialog"]`. + +A trigger given plain text is unchanged and needs nothing. + ## Component inventory (by family) - **Layout/primitives**: Flex, Grid, Join, Card, Separator, ScrollArea, Skeleton, Empty, Footer, Header, Navbar, Toolbar, Dock diff --git a/src/components/drawer/Drawer.layout.tsx b/src/components/drawer/Drawer.layout.tsx index 0ea90ff1..f56b2fe2 100644 --- a/src/components/drawer/Drawer.layout.tsx +++ b/src/components/drawer/Drawer.layout.tsx @@ -1,6 +1,6 @@ import "./Drawer.css"; -import {Show, createSignal, createTrackedEffect, createUniqueId, onCleanup, omit, type Component, type ParentComponent} from "solid-js"; -import { Portal, type JSX} from "@solidjs/web"; +import {Show, createSignal, createTrackedEffect, createUniqueId, onCleanup, omit, type Component, type ParentComponent, type ValidComponent} from "solid-js"; +import { Dynamic, Portal, type JSX} from "@solidjs/web"; import { twMerge } from "../../lib/twMerge"; import { lockBodyScroll, registerOverlay } from "../../lib/overlay"; import "../_shared/material.css"; @@ -61,6 +61,29 @@ export type DrawerTriggerProps = Omit< > & UIBaseProps & { children: JSX.Element; + /** + * The component to be the trigger, instead of a bare button. + * + * `Drawer.Trigger` renders a `button`, which is right when the trigger is + * a word or a glyph and wrong when the call site hands it a control: + * `` produced a + * `button` wrapping a `button` at an identical box with an identical name, + * so every trigger was announced twice and the press by coordinate landed + * on the outer one rather than on the control that was written. Nested + * interactive content is also invalid HTML. + * + * `as` makes the control *be* the trigger, so there is one element: + * + * ```tsx + * Menu + * ``` + * + * The wiring and anything else written here go to the delegate. The + * trigger's own class is a button reset and is dropped, because a control + * does not want one; so is `data-slot="drawer-trigger"`, since the + * delegate's recipe owns that attribute. + */ + as?: ValidComponent; }; export type DrawerBackdropProps = Omit, "children"> & @@ -353,7 +376,15 @@ const DrawerRoot: Layout = () => { }; const DrawerTrigger: Layout = () => { - const others = omit(props, "children", "class", "dataTheme", "style", "onClick"); + const others = omit( + props, + "children", + "class", + "dataTheme", + "style", + "onClick", + "as", + ); const ctx = useDrawerContext(); @@ -362,18 +393,41 @@ const DrawerTrigger: Layout = () => if (typeof props.onClick === "function") props.onClick(event); }; + /* + * The default branch stays a literal ` + } > - {props.children} - + + {props.children} + + ); }; diff --git a/src/components/popover/Popover.layout.tsx b/src/components/popover/Popover.layout.tsx index 1608ab6f..9f0d0c2b 100644 --- a/src/components/popover/Popover.layout.tsx +++ b/src/components/popover/Popover.layout.tsx @@ -1,5 +1,5 @@ import "./Popover.css"; -import { type JSX, Portal } from "@solidjs/web"; +import { Dynamic, type JSX, Portal } from "@solidjs/web"; import { createContext, createMemo, @@ -9,6 +9,7 @@ import { onSettled, Show, useContext, + type ValidComponent, } from "solid-js"; import { twMerge } from "../../lib/twMerge"; import { registerOverlay } from "../../lib/overlay"; @@ -256,6 +257,31 @@ const PopoverRoot: Layout = () => { export type PopoverTriggerProps = UIBaseProps & Omit, "children"> & { children: JSX.Element; + /** + * The component to be the trigger, instead of a bare button. + * + * `Popover.Trigger` renders a `button`, which is right when the trigger is + * a word or a glyph. It is wrong when the call site hands it a control: + * `` emitted a + * second, anonymous button wrapping the named one at identical + * coordinates. Found on three separate sites. Nested interactive content + * is invalid HTML, and a press by coordinate lands on the anonymous outer + * one rather than on the control that was written. + * + * `as` makes the control *be* the trigger, so there is one element: + * + * ```tsx + * Filters + * ``` + * + * The wiring -- id, `aria-haspopup`, `aria-expanded`, `aria-controls`, the + * ref and both handlers -- goes to the delegate, and anything else written + * here goes with it. The trigger's own class is a button reset and is + * dropped, because a control does not want one; so is + * `data-slot="popover-trigger"`, since the delegate's recipe owns that + * attribute. Select the delegate's own slot, or `[aria-haspopup="dialog"]`. + */ + as?: ValidComponent; }; const PopoverTrigger: Layout< @@ -271,6 +297,7 @@ const PopoverTrigger: Layout< "type", "onClick", "onKeyDown", + "as", ); const ctx = usePopoverContext(); @@ -295,24 +322,55 @@ const PopoverTrigger: Layout< } }; + /* + * The delegate branch is a `Dynamic`; the default branch stays a literal + * ` + } > - {props.children} - + ctx.setTriggerRef(el)} + type={props.type ?? "button"} + id={ctx.triggerId()} + class={props.class} + data-theme={props.dataTheme} + style={props.style} + aria-haspopup="dialog" + aria-expanded={ctx.isOpen() ? "true" : "false"} + aria-controls={ctx.isOpen() ? ctx.contentId() : undefined} + onClick={handleClick} + onKeyDown={handleKeyDown} + > + {props.children} + + ); }; diff --git a/tests/components/trigger-delegation.test.ts b/tests/components/trigger-delegation.test.ts new file mode 100644 index 00000000..6e1e1cb1 --- /dev/null +++ b/tests/components/trigger-delegation.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * A trigger that is handed a control must not wrap it in a second one. + * + * `Popover.Trigger` and `Drawer.Trigger` each render a `button`. That is right + * when the trigger is a word or a glyph, and wrong the moment a call site + * hands one a control, which is the common way both are written: + * + * + * + * + * The popover form was measured on three separate sites, emitting an + * anonymous twin button at identical coordinates carrying + * `slot=popover-trigger`; the drawer form put `slot=drawer-trigger` around + * `slot=button` at an identical box with an identical name, so every trigger + * was announced twice. Nested interactive content is invalid HTML either way, + * and a press by coordinate lands on the outer element rather than on the + * control that was written. + * + * `as` makes the control *be* the trigger, so one element carries the name, + * the box and the wiring. + * + * ## Why the default branch is still a literal element + * + * Only the delegate branch is a `Dynamic`. `Button` already found that a + * `Dynamic` over a *string* element painted correctly under Blitz and dropped + * a nested consumer's event binding, leaving an enabled control that + * acknowledged activation without running its handler. On the element that + * opens a popover or a drawer that is the entire component, so the ordinary + * path keeps the literal ` + {/* biome-ignore lint/a11y/useSemanticElements: an h2 brings the user agent's default margin into every consumer that does not reset it, which moves the header; the role names the month in the accessibility tree and changes no box. */}
- {calendarState.monthFormatter().format(navigation.visibleMonth())} + {calendarState.monthLabel()}
@@ -431,7 +466,7 @@ const Calendar: Layout = () => { data-disabled={cellState.isDisabled ? "true" : "false"} data-unavailable={cellState.isUnavailable ? "true" : "false"} role="gridcell" - aria-label={calendarState.dayLabelFormatter().format(date)} + aria-label={calendarState.formatDayLabel(date)} aria-selected={cellState.isAriaSelected ? "true" : "false"} aria-disabled={cellState.isDisabled ? "true" : "false"} disabled={cellState.isDisabled} diff --git a/src/components/calendar/index.ts b/src/components/calendar/index.ts index b2844000..d61e0420 100644 --- a/src/components/calendar/index.ts +++ b/src/components/calendar/index.ts @@ -4,5 +4,6 @@ export { type CalendarProps, type CalendarSelectionMode, type CalendarWeekdayFormat, + type DateNames, default, } from "./Calendar.generated"; diff --git a/src/components/date-picker/DatePicker.layout.tsx b/src/components/date-picker/DatePicker.layout.tsx index af5eb228..2c4564a6 100644 --- a/src/components/date-picker/DatePicker.layout.tsx +++ b/src/components/date-picker/DatePicker.layout.tsx @@ -5,11 +5,15 @@ import { twMerge } from "../../lib/twMerge"; import { formatDate, + resolveDateNames, toISODate, useDateSelection, usePickerOpenState, } from "../../hooks/date"; -import Calendar, { type CalendarWeekdayFormat } from "../calendar"; +import Calendar, { + type CalendarWeekdayFormat, + type DateNames, +} from "../calendar"; import type { UIBaseProps, State } from "../vocabulary"; import { CLASSES } from "./DatePicker.recipe"; import type { Layout } from "../../lib/layouts"; @@ -25,6 +29,8 @@ type DatePickerBaseProps = { name?: string; placeholder?: string; locale?: string; + /** Month and weekday names for a language other than English. See `Calendar`. */ + dateNames?: DateNames; weekdayFormat?: CalendarWeekdayFormat; minValue?: Date; maxValue?: Date; @@ -56,6 +62,7 @@ const DatePicker: Layout = () => { "name", "placeholder", "locale", + "dateNames", "weekdayFormat", "minValue", "maxValue", @@ -79,13 +86,19 @@ const DatePicker: Layout = () => { isDisabled, }); - const locale = createMemo(() => props.locale ?? "en-US"); + /** + * The trigger text renders from the same table the calendar inside the + * popover does, so the two never disagree about the month. + */ + const dateNames = createMemo( + () => resolveDateNames(props.locale, props.dateNames).names, + ); const displayValue = createMemo(() => { const selectedDate = selection.selectedDate(); if (!selectedDate) return props.placeholder ?? "Select date"; - return formatDate(selectedDate, locale()); + return formatDate(selectedDate, dateNames()); }); const uniqueId = createUniqueId(); @@ -188,7 +201,8 @@ const DatePicker: Layout = () => { data-slot="date-picker-calendar" value={selection.selectedDate() ?? undefined} onChange={handleDateChange} - locale={locale()} + locale={props.locale} + dateNames={props.dateNames} weekdayFormat={props.weekdayFormat} minValue={props.minValue} maxValue={props.maxValue} diff --git a/src/components/date-range-picker/DateRangePicker.layout.tsx b/src/components/date-range-picker/DateRangePicker.layout.tsx index 02af7016..63df49c9 100644 --- a/src/components/date-range-picker/DateRangePicker.layout.tsx +++ b/src/components/date-range-picker/DateRangePicker.layout.tsx @@ -5,12 +5,16 @@ import { twMerge } from "../../lib/twMerge"; import { formatDate, + resolveDateNames, toISODate, usePickerOpenState, useRangeSelection, type ControlledDateRangeValue, } from "../../hooks/date"; -import Calendar, { type CalendarWeekdayFormat } from "../calendar"; +import Calendar, { + type CalendarWeekdayFormat, + type DateNames, +} from "../calendar"; import type { UIBaseProps, State } from "../vocabulary"; import { CLASSES } from "./DateRangePicker.recipe"; import type { Layout } from "../../lib/layouts"; @@ -30,6 +34,8 @@ type DateRangePickerBaseProps = { startPlaceholder?: string; endPlaceholder?: string; locale?: string; + /** Month and weekday names for a language other than English. See `Calendar`. */ + dateNames?: DateNames; weekdayFormat?: CalendarWeekdayFormat; minValue?: Date; maxValue?: Date; @@ -63,6 +69,7 @@ const DateRangePicker: Layout = () "startPlaceholder", "endPlaceholder", "locale", + "dateNames", "weekdayFormat", "minValue", "maxValue", @@ -91,7 +98,13 @@ const DateRangePicker: Layout = () rangeSelection.clearPendingSelection(); }); - const locale = createMemo(() => props.locale ?? "en-US"); + /** + * The trigger text renders from the same table the calendar inside the + * popover does, so the two never disagree about the month. + */ + const dateNames = createMemo( + () => resolveDateNames(props.locale, props.dateNames).names, + ); const startValue = createMemo(() => rangeSelection.rangeStart()); const endValue = createMemo(() => rangeSelection.rangeEnd()); @@ -99,12 +112,12 @@ const DateRangePicker: Layout = () const startDisplay = createMemo(() => { if (!startValue()) return props.startPlaceholder ?? "Start date"; - return formatDate(startValue(), locale()); + return formatDate(startValue(), dateNames()); }); const endDisplay = createMemo(() => { if (!endValue()) return props.endPlaceholder ?? "End date"; - return formatDate(endValue(), locale()); + return formatDate(endValue(), dateNames()); }); const handleDateSelect = (date: Date) => { @@ -238,7 +251,8 @@ const DateRangePicker: Layout = () rangePreview={rangeSelection.hoveredDate() ?? undefined} onDaySelect={handleDateSelect} onDayHover={rangeSelection.setHoverDate} - locale={locale()} + locale={props.locale} + dateNames={props.dateNames} weekdayFormat={props.weekdayFormat} minValue={props.minValue} maxValue={props.maxValue} diff --git a/src/components/range-calendar/RangeCalendar.layout.tsx b/src/components/range-calendar/RangeCalendar.layout.tsx index 39530879..f0971c65 100644 --- a/src/components/range-calendar/RangeCalendar.layout.tsx +++ b/src/components/range-calendar/RangeCalendar.layout.tsx @@ -7,7 +7,10 @@ import { useRangeSelection, type ControlledDateRangeValue, } from "../../hooks/date"; -import Calendar, { type CalendarWeekdayFormat } from "../calendar"; +import Calendar, { + type CalendarWeekdayFormat, + type DateNames, +} from "../calendar"; import type { UIBaseProps, State } from "../vocabulary"; import { CLASSES } from "./RangeCalendar.recipe"; import type { Layout } from "../../lib/layouts"; @@ -20,6 +23,8 @@ type RangeCalendarBaseProps = { defaultValue?: RangeCalendarValue; onChange?: (value: RangeCalendarValue) => void; locale?: string; + /** Month and weekday names for a language other than English. See `Calendar`. */ + dateNames?: DateNames; weekdayFormat?: CalendarWeekdayFormat; minValue?: Date; maxValue?: Date; @@ -49,6 +54,7 @@ const RangeCalendar: Layout = () => "defaultValue", "onChange", "locale", + "dateNames", "weekdayFormat", "minValue", "maxValue", @@ -105,6 +111,7 @@ const RangeCalendar: Layout = () => rangeEnd={rangeSelection.rangeEnd() ?? undefined} rangePreview={rangeSelection.hoveredDate() ?? undefined} locale={props.locale} + dateNames={props.dateNames} weekdayFormat={props.weekdayFormat} minValue={props.minValue} maxValue={props.maxValue} diff --git a/src/hooks/date/date.names.ts b/src/hooks/date/date.names.ts new file mode 100644 index 00000000..389426f7 --- /dev/null +++ b/src/hooks/date/date.names.ts @@ -0,0 +1,177 @@ +/** + * Month and weekday names, and the four date strings the calendar assembles + * from them, without `Intl`. + * + * `Intl` is not defined in the chuzz browser. That is deliberate policy and + * the decision has been made not to ship ICU data, so `new Intl.DateTimeFormat` + * is not a formatter that returns a bad date, it is a `ReferenceError`. The + * throw escapes every boundary the component has and reaches Solid 2, which + * responds by halting its reactive system permanently: the page keeps painting + * the frame it already had, so it looks alive, while every control on it is + * dead. js.software's `/calendar` route is dead this way today, and nothing on + * the page says so. + * + * What the calendar actually needed from `Intl` was twelve month names, seven + * weekday names in three widths, and four assembly patterns. That is a table, + * so this is the table. + * + * ## The locale question + * + * Only `en-US` is carried here. A library that shipped name tables for every + * locale would be shipping ICU data by another name, which is the thing that + * was ruled out. + * + * The sites are not all English, so the names are an input: pass `dateNames` + * and the calendar renders yours. A site that already ships five locales + * through its own i18n has these strings; it hands over the set for the + * language it is currently in. + * + * When a consumer passes `locale="de-DE"` and no `dateNames`, the calendar + * renders English. It cannot do anything else, and the two dishonest options + * were to throw or to keep claiming German while showing "June". So it renders + * English *and says so*: `resolveDateNames` returns the locale actually + * rendered, and `Calendar` puts that on the root as `lang`. A page asking for + * German gets `lang="en-US"`, which is true, which an assistive technology + * reads correctly, and which a check can catch. + */ + +/** Weekday name width, matching the widths `Intl` calls `weekday`. */ +export type DateNameWidth = "narrow" | "short" | "long"; + +/** + * The names a calendar needs to render. + * + * Every array is indexed positionally: months by `Date#getMonth` (0 is + * January), weekdays by `Date#getDay` (0 is Sunday). All five are required. + * A partial set was the friendlier API and the wrong one: German months beside + * English weekdays under `lang="de-DE"` is exactly the half-truth this module + * exists to avoid. + */ +export type DateNames = { + /** 12 entries, `getMonth`-indexed. "January". */ + monthsLong: readonly string[]; + /** 12 entries, `getMonth`-indexed. "Jan". */ + monthsShort: readonly string[]; + /** 7 entries, `getDay`-indexed from Sunday. "S". */ + weekdaysNarrow: readonly string[]; + /** 7 entries, `getDay`-indexed from Sunday. "Sun". */ + weekdaysShort: readonly string[]; + /** 7 entries, `getDay`-indexed from Sunday. "Sunday". */ + weekdaysLong: readonly string[]; +}; + +/** The locale the built-in table transcribes. */ +export const DEFAULT_DATE_LOCALE = "en-US"; + +/** + * `en-US`, transcribed from `Intl` rather than typed from memory. + * + * `tests/hooks/date/date-names.test.ts` regenerates every entry from a real + * `Intl.DateTimeFormat` under bun's ICU and compares, so a typo here is a test + * failure and not a wrong month in production. + */ +export const EN_US_DATE_NAMES: DateNames = { + monthsLong: [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ], + monthsShort: [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ], + weekdaysNarrow: ["S", "M", "T", "W", "T", "F", "S"], + weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + weekdaysLong: [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ], +}; + +/** + * Pick the names to render with, and report the locale that will actually be + * on screen. + * + * The returned `locale` is the honest one, not the requested one: it is the + * consumer's locale only when the consumer supplied the names to back it. + */ +export const resolveDateNames = ( + locale: string | undefined, + names: DateNames | undefined, +): { names: DateNames; locale: string } => + names + ? { names, locale: locale ?? DEFAULT_DATE_LOCALE } + : { names: EN_US_DATE_NAMES, locale: DEFAULT_DATE_LOCALE }; + +/** + * The grid heading. `{ month: "long", year: "numeric" }` in `en-US`. + * + * "June 2025". + */ +export const formatMonthYear = ( + date: Date, + names: DateNames = EN_US_DATE_NAMES, +) => `${names.monthsLong[date.getMonth()] ?? ""} ${date.getFullYear()}`; + +/** + * A day button's `aria-label`. `{ dateStyle: "full" }` in `en-US`. + * + * "Sunday, June 15, 2025": long weekday, comma, long month, space, the day + * with no leading zero, comma, year. + */ +export const formatFullDate = ( + date: Date, + names: DateNames = EN_US_DATE_NAMES, +) => + `${names.weekdaysLong[date.getDay()] ?? ""}, ${names.monthsLong[date.getMonth()] ?? ""} ${date.getDate()}, ${date.getFullYear()}`; + +/** + * A picker's trigger text. `{ day: "numeric", month: "short", year: "numeric" }` + * in `en-US`. + * + * "Jun 15, 2025". Note that `Intl` orders this month-first for `en-US` + * regardless of the order the options are written in. + */ +export const formatCompactDate = ( + date: Date, + names: DateNames = EN_US_DATE_NAMES, +) => + `${names.monthsShort[date.getMonth()] ?? ""} ${date.getDate()}, ${date.getFullYear()}`; + +/** + * The seven column headers, Sunday first. `{ weekday: width }` in `en-US`. + * + * `["Sun", "Mon", ...]` at the default `short` width. + */ +export const weekdayNames = ( + width: DateNameWidth, + names: DateNames = EN_US_DATE_NAMES, +): readonly string[] => { + if (width === "narrow") return names.weekdaysNarrow; + if (width === "long") return names.weekdaysLong; + return names.weekdaysShort; +}; diff --git a/src/hooks/date/date.utils.ts b/src/hooks/date/date.utils.ts index f561361f..d5bb41aa 100644 --- a/src/hooks/date/date.utils.ts +++ b/src/hooks/date/date.utils.ts @@ -1,3 +1,9 @@ +import { + type DateNames, + EN_US_DATE_NAMES, + formatCompactDate, +} from "./date.names"; + export const DAYS_PER_WEEK = 7; export const CALENDAR_GRID_DAYS = 42; @@ -60,19 +66,23 @@ export const parseDate = (value: string | null | undefined): Date | null => { return parsed; }; +/** + * The date a picker shows on its trigger. "Jun 15, 2025". + * + * The second parameter used to be a locale string and the third an + * `Intl.DateTimeFormatOptions`. Both are gone rather than kept as ignored + * arguments: a `locale` this function cannot honour is worse than no `locale` + * at all, and the component that owns the locale prop resolves it through + * `resolveDateNames` before calling here. See `date.names.ts`. + */ export const formatDate = ( value: Date | null | undefined, - locale = "en-US", - options: Intl.DateTimeFormatOptions = { - day: "numeric", - month: "short", - year: "numeric", - }, + names: DateNames = EN_US_DATE_NAMES, ) => { const date = normalizeDate(value); if (!date) return ""; - return new Intl.DateTimeFormat(locale, options).format(date); + return formatCompactDate(date, names); }; const pad = (value: number) => String(value).padStart(2, "0"); diff --git a/src/hooks/date/index.ts b/src/hooks/date/index.ts index f1855eb7..6d8ae3da 100644 --- a/src/hooks/date/index.ts +++ b/src/hooks/date/index.ts @@ -1,3 +1,4 @@ +export * from "./date.names"; export * from "./date.utils"; export * from "./useCalendarNavigation"; export * from "./useCalendarState"; diff --git a/src/hooks/date/useCalendarState.ts b/src/hooks/date/useCalendarState.ts index ee37364a..e33bb452 100644 --- a/src/hooks/date/useCalendarState.ts +++ b/src/hooks/date/useCalendarState.ts @@ -1,7 +1,13 @@ import { type Accessor, createMemo } from "solid-js"; import { - addDays, + type DateNameWidth, + type DateNames, + formatFullDate, + formatMonthYear, + weekdayNames, +} from "./date.names"; +import { buildCalendarGrid, compareDates, createPreviewRange, @@ -17,8 +23,14 @@ export type CalendarSelectionMode = "single" | "range"; type CalendarStateOptions = { selectionMode: Accessor; - locale: Accessor; - weekdayFormat: Accessor<"narrow" | "short" | "long">; + /** + * The names to render with. The owning component resolves these from its + * `locale` and `dateNames` props through `resolveDateNames`, so there is no + * `locale` here: this hook formats from a table and has nothing to do with + * one. See `date.names.ts`. + */ + dateNames: Accessor; + weekdayFormat: Accessor; visibleMonth: Accessor; focusedDate: Accessor; selectedDate: Accessor; @@ -45,32 +57,20 @@ export type CalendarCellState = { }; export const useCalendarState = (options: CalendarStateOptions) => { - const monthFormatter = createMemo( - () => - new Intl.DateTimeFormat(options.locale(), { - month: "long", - year: "numeric", - }), + /** The grid heading, e.g. "June 2025". */ + const monthLabel = createMemo(() => + formatMonthYear(options.visibleMonth(), options.dateNames()), ); - const dayLabelFormatter = createMemo( - () => new Intl.DateTimeFormat(options.locale(), { dateStyle: "full" }), - ); + /** A day button's `aria-label`, e.g. "Sunday, June 15, 2025". */ + const formatDayLabel = (date: Date) => + formatFullDate(date, options.dateNames()); - const weekdayFormatter = createMemo( - () => - new Intl.DateTimeFormat(options.locale(), { - weekday: options.weekdayFormat(), - }), + /** The seven column headers, Sunday first. */ + const weekdayLabels = createMemo(() => + weekdayNames(options.weekdayFormat(), options.dateNames()), ); - const weekdayLabels = createMemo(() => { - const firstSunday = new Date(2024, 0, 7, 12, 0, 0, 0); - return Array.from({ length: 7 }, (_, index) => - weekdayFormatter().format(addDays(firstSunday, index)), - ); - }); - const calendarWeeks = createMemo(() => splitWeeks(buildCalendarGrid(options.visibleMonth(), 0)), ); @@ -149,8 +149,8 @@ export const useCalendarState = (options: CalendarStateOptions) => { }; return { - monthFormatter, - dayLabelFormatter, + monthLabel, + formatDayLabel, weekdayLabels, calendarWeeks, normalizedRange, diff --git a/src/index.ts b/src/index.ts index 86ed9d8f..d4ccf7e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,7 @@ export { type CalendarProps, type CalendarSelectionMode, type CalendarWeekdayFormat, + type DateNames, default as Calendar, } from "./components/calendar"; export type { diff --git a/tests/hooks/date/date-names.test.ts b/tests/hooks/date/date-names.test.ts new file mode 100644 index 00000000..dba0a266 --- /dev/null +++ b/tests/hooks/date/date-names.test.ts @@ -0,0 +1,464 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createMemo, createRoot } from "solid-js"; + +import { + type DateNameWidth, + type DateNames, + DEFAULT_DATE_LOCALE, + EN_US_DATE_NAMES, + formatCompactDate, + formatFullDate, + formatMonthYear, + resolveDateNames, + weekdayNames, +} from "../../../src/hooks/date/date.names"; +import { formatDate } from "../../../src/hooks/date/date.utils"; +import { useCalendarState } from "../../../src/hooks/date/useCalendarState"; + +/** + * The calendar renders its dates from a table, and the table is `en-US`. + * + * `Intl` is undefined in the chuzz browser by policy, and ICU data is + * deliberately not shipped, so `new Intl.DateTimeFormat` there is a + * `ReferenceError` rather than a formatter with a bad answer. That throw is + * not survivable: it escapes the component, reaches Solid 2, and Solid 2 + * halts its reactive system permanently. The page keeps painting the frame it + * already had, so it looks fine, while every control on it is dead. + * js.software's `/calendar` route is dead this way today, and nothing visible + * says so, which is why "it renders" is not evidence and this file exists. + * + * Two halves, and both are needed: + * + * 1. The strings must be **byte-identical to `Intl`**, not merely plausible. + * Other repositories hold acceptance checks asserting these exact + * strings. So the expectations here are not typed from memory: they are + * regenerated from a real `Intl.DateTimeFormat` under bun's own ICU on + * every run and compared. A typo in the table fails here instead of + * shipping a wrong month. + * + * 2. The modules must not **reference** `Intl` at all. A test that only + * compares output would pass forever under bun, which has `Intl`, while + * the browser that matters does not. So the last case walks the touched + * sources for the identifier, and one case runs the hook with the global + * deleted, which is the closest this runner gets to standing in chuzz. + */ + +/* ------------------------------------------------------------------------------------------------- + * Ground truth, regenerated from real `Intl` rather than remembered + * -----------------------------------------------------------------------------------------------*/ + +const L = DEFAULT_DATE_LOCALE; + +/** Noon, so no time zone can push a date onto the neighbouring day. */ +const at = (year: number, monthIndex: number, day: number) => + new Date(year, monthIndex, day, 12, 0, 0, 0); + +/** The seven days of an ordinary week beginning on a Sunday. */ +const WEEK = Array.from({ length: 7 }, (_, index) => at(2024, 0, 7 + index)); + +const intlNames = (): DateNames => ({ + monthsLong: Array.from({ length: 12 }, (_, m) => + new Intl.DateTimeFormat(L, { month: "long" }).format(at(2024, m, 15)), + ), + monthsShort: Array.from({ length: 12 }, (_, m) => + new Intl.DateTimeFormat(L, { month: "short" }).format(at(2024, m, 15)), + ), + weekdaysNarrow: WEEK.map((date) => + new Intl.DateTimeFormat(L, { weekday: "narrow" }).format(date), + ), + weekdaysShort: WEEK.map((date) => + new Intl.DateTimeFormat(L, { weekday: "short" }).format(date), + ), + weekdaysLong: WEEK.map((date) => + new Intl.DateTimeFormat(L, { weekday: "long" }).format(date), + ), +}); + +/** + * The corpus. Every date here is a shape that has an opinion about the format: + * a single-digit day, the first and last day of a year, a leap day, a + * four-digit year that is not this decade. + */ +const CORPUS = [ + at(2025, 5, 15), // the example in the issue: Sunday, June 15, 2025 + at(2025, 0, 1), // January, single-digit day, first of the year + at(2025, 11, 31), // December, two-digit day, last of the year + at(2024, 1, 29), // leap day + at(1999, 8, 9), // single-digit day and a September short name + at(2025, 6, 4), // single-digit day mid-year + at(2025, 8, 30), // "Sep" is the short name, not "Sept" +]; + +describe("en-US date names match Intl", () => { + it("transcribes every month and weekday name Intl produces", () => { + expect(EN_US_DATE_NAMES).toEqual(intlNames()); + }); + + it("keeps the tables the length the indexing assumes", () => { + expect(EN_US_DATE_NAMES.monthsLong).toHaveLength(12); + expect(EN_US_DATE_NAMES.monthsShort).toHaveLength(12); + expect(EN_US_DATE_NAMES.weekdaysNarrow).toHaveLength(7); + expect(EN_US_DATE_NAMES.weekdaysShort).toHaveLength(7); + expect(EN_US_DATE_NAMES.weekdaysLong).toHaveLength(7); + }); +}); + +describe("the four patterns are byte-identical to Intl", () => { + it("renders the grid heading as { month: long, year: numeric }", () => { + for (const date of CORPUS) { + expect(formatMonthYear(date)).toBe( + new Intl.DateTimeFormat(L, { + month: "long", + year: "numeric", + }).format(date), + ); + } + + expect(formatMonthYear(at(2025, 5, 15))).toBe("June 2025"); + expect(formatMonthYear(at(2025, 0, 1))).toBe("January 2025"); + expect(formatMonthYear(at(2025, 11, 31))).toBe("December 2025"); + }); + + it("renders a day's aria-label as { dateStyle: full }", () => { + for (const date of CORPUS) { + expect(formatFullDate(date)).toBe( + new Intl.DateTimeFormat(L, { dateStyle: "full" }).format(date), + ); + } + + // Long weekday, comma, long month, space, unpadded day, comma, year. + expect(formatFullDate(at(2025, 5, 15))).toBe("Sunday, June 15, 2025"); + expect(formatFullDate(at(2025, 0, 1))).toBe("Wednesday, January 1, 2025"); + expect(formatFullDate(at(2024, 1, 29))).toBe("Thursday, February 29, 2024"); + }); + + it("renders the column headers as { weekday: width } at all three widths", () => { + const widths: DateNameWidth[] = ["narrow", "short", "long"]; + + for (const width of widths) { + expect(weekdayNames(width)).toEqual( + WEEK.map((date) => + new Intl.DateTimeFormat(L, { weekday: width }).format(date), + ), + ); + } + + expect(weekdayNames("narrow")).toEqual(["S", "M", "T", "W", "T", "F", "S"]); + expect(weekdayNames("short")).toEqual([ + "Sun", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + ]); + expect(weekdayNames("long")[0]).toBe("Sunday"); + expect(weekdayNames("long")[6]).toBe("Saturday"); + }); + + it("renders a picker's trigger as { day, month: short, year }", () => { + for (const date of CORPUS) { + expect(formatCompactDate(date)).toBe( + new Intl.DateTimeFormat(L, { + day: "numeric", + month: "short", + year: "numeric", + }).format(date), + ); + // `formatDate` is the picker's entry point and normalises first, so it + // has to agree with the pattern it delegates to. + expect(formatDate(date)).toBe(formatCompactDate(date)); + } + + expect(formatDate(at(2025, 5, 15))).toBe("Jun 15, 2025"); + expect(formatDate(at(1999, 8, 9))).toBe("Sep 9, 1999"); + expect(formatDate(null)).toBe(""); + expect(formatDate(undefined)).toBe(""); + expect(formatDate(new Date(Number.NaN))).toBe(""); + }); +}); + +/* ------------------------------------------------------------------------------------------------- + * A consumer's own language + * -----------------------------------------------------------------------------------------------*/ + +const DE_DE: DateNames = { + monthsLong: [ + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember", + ], + monthsShort: [ + "Jan", + "Feb", + "Mär", + "Apr", + "Mai", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Dez", + ], + weekdaysNarrow: ["S", "M", "D", "M", "D", "F", "S"], + weekdaysShort: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], + weekdaysLong: [ + "Sonntag", + "Montag", + "Dienstag", + "Mittwoch", + "Donnerstag", + "Freitag", + "Samstag", + ], +}; + +describe("a consumer supplies its own names", () => { + it("renders every pattern from the names it was given", () => { + const date = at(2025, 5, 15); + + expect(formatMonthYear(date, DE_DE)).toBe("Juni 2025"); + expect(formatFullDate(date, DE_DE)).toBe("Sonntag, Juni 15, 2025"); + expect(formatCompactDate(date, DE_DE)).toBe("Jun 15, 2025"); + expect(weekdayNames("short", DE_DE)).toEqual([ + "So", + "Mo", + "Di", + "Mi", + "Do", + "Fr", + "Sa", + ]); + }); + + /** + * The assembly order stays `en-US`. A German site gets German words in the + * English arrangement, which is a real limitation and is written down in + * `docs/ui-usage.md` rather than hidden: a table of names is not a + * pattern-per-locale formatter, and the alternative was shipping ICU. + */ + it("keeps the en-US assembly order, which is the documented limit", () => { + expect(formatFullDate(at(2025, 5, 15), DE_DE)).not.toBe( + "Sonntag, 15. Juni 2025", + ); + }); +}); + +describe("an unsupported locale resolves honestly", () => { + it("falls back to English and reports English, never the locale asked for", () => { + const resolved = resolveDateNames("de-DE", undefined); + + expect(resolved.names).toBe(EN_US_DATE_NAMES); + // The point: it does not claim to be German while showing "June". + expect(resolved.locale).toBe("en-US"); + }); + + it("does not throw on a locale that is not a locale", () => { + for (const locale of ["", "xx", "not a locale", "de-DE-u-ca-buddhist"]) { + expect(() => resolveDateNames(locale, undefined)).not.toThrow(); + expect(resolveDateNames(locale, undefined).locale).toBe("en-US"); + } + }); + + it("honours the locale once names back it", () => { + const resolved = resolveDateNames("de-DE", DE_DE); + + expect(resolved.names).toBe(DE_DE); + expect(resolved.locale).toBe("de-DE"); + }); + + it("defaults the locale when names arrive without one", () => { + expect(resolveDateNames(undefined, DE_DE).locale).toBe("en-US"); + expect(resolveDateNames(undefined, undefined).locale).toBe("en-US"); + }); +}); + +/* ------------------------------------------------------------------------------------------------- + * The hook, with no `Intl` to reach for + * -----------------------------------------------------------------------------------------------*/ + +const runCalendarState = ( + visibleMonth: Date, + weekdayFormat: DateNameWidth, + names: DateNames, +) => + createRoot((dispose) => { + const state = useCalendarState({ + selectionMode: () => "single", + dateNames: createMemo(() => names), + weekdayFormat: createMemo(() => weekdayFormat), + visibleMonth: createMemo(() => visibleMonth), + focusedDate: createMemo(() => visibleMonth), + selectedDate: createMemo(() => null), + rangeStart: createMemo(() => null), + rangeEnd: createMemo(() => null), + rangePreview: createMemo(() => null), + isDateDisabled: () => false, + isDateUnavailable: () => false, + }); + + const captured = { + monthLabel: state.monthLabel(), + weekdayLabels: [...state.weekdayLabels()], + firstDayLabel: state.formatDayLabel(state.calendarWeeks()[0][0]), + fifteenthLabel: state.formatDayLabel( + new Date( + visibleMonth.getFullYear(), + visibleMonth.getMonth(), + 15, + 12, + 0, + 0, + 0, + ), + ), + }; + + dispose(); + return captured; + }); + +describe("the calendar formats with Intl removed from the global", () => { + /** + * The closest this runner gets to standing in chuzz. Before the fix this + * case does not fail an assertion, it throws `ReferenceError: Intl is not + * defined` out of the first `createMemo`, which is exactly the shape of the + * production failure. + */ + it("produces every label with no Intl in scope", () => { + const saved = Reflect.get(globalThis, "Intl"); + Reflect.deleteProperty(globalThis, "Intl"); + + try { + expect(Reflect.has(globalThis, "Intl")).toBeFalse(); + + const june = runCalendarState(at(2025, 5, 1), "short", EN_US_DATE_NAMES); + + expect(june.monthLabel).toBe("June 2025"); + expect(june.weekdayLabels).toEqual([ + "Sun", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + ]); + // June 2025 begins on a Sunday, so the grid opens on the 1st. + expect(june.firstDayLabel).toBe("Sunday, June 1, 2025"); + expect(june.fifteenthLabel).toBe("Sunday, June 15, 2025"); + + const january = runCalendarState( + at(2025, 0, 1), + "narrow", + EN_US_DATE_NAMES, + ); + expect(january.monthLabel).toBe("January 2025"); + expect(january.weekdayLabels).toEqual([ + "S", + "M", + "T", + "W", + "T", + "F", + "S", + ]); + expect(january.fifteenthLabel).toBe("Wednesday, January 15, 2025"); + + const december = runCalendarState( + at(2025, 11, 1), + "long", + EN_US_DATE_NAMES, + ); + expect(december.monthLabel).toBe("December 2025"); + expect(december.weekdayLabels[0]).toBe("Sunday"); + expect(december.weekdayLabels[6]).toBe("Saturday"); + + const german = runCalendarState(at(2025, 5, 1), "short", DE_DE); + expect(german.monthLabel).toBe("Juni 2025"); + expect(german.weekdayLabels[0]).toBe("So"); + } finally { + Reflect.set(globalThis, "Intl", saved); + } + + expect(Reflect.has(globalThis, "Intl")).toBeTrue(); + }); +}); + +/* ------------------------------------------------------------------------------------------------- + * The identifier itself + * -----------------------------------------------------------------------------------------------*/ + +const SRC = join(import.meta.dir, "../../../src"); + +/** + * Every module on the path from a calendar prop to a rendered date string. + * + * The `.generated.tsx` twins are compiled from their `.layout.tsx` sources, so + * a reference in one is the same reference in the other, and the twin is the + * file that actually ships. + */ +const TOUCHED = [ + "hooks/date/date.names.ts", + "hooks/date/date.utils.ts", + "hooks/date/useCalendarState.ts", + "components/calendar/Calendar.layout.tsx", + "components/calendar/Calendar.generated.tsx", + "components/date-picker/DatePicker.layout.tsx", + "components/date-picker/DatePicker.generated.tsx", + "components/date-range-picker/DateRangePicker.layout.tsx", + "components/date-range-picker/DateRangePicker.generated.tsx", + "components/range-calendar/RangeCalendar.layout.tsx", + "components/range-calendar/RangeCalendar.generated.tsx", +]; + +/** A line that is only prose. These modules discuss `Intl` at length. */ +const isComment = (line: string) => /^\s*(\/\/|\/?\*|\{\/\*)/.test(line); + +describe("no module on the calendar's path references Intl", () => { + it("reads every file it claims to check, so a bad path cannot pass", () => { + for (const relative of TOUCHED) { + const text = readFileSync(join(SRC, relative), "utf8"); + expect(text.length).toBeGreaterThan(0); + } + }); + + it("finds no Intl identifier outside a comment", () => { + const hits: string[] = []; + + for (const relative of TOUCHED) { + const lines = readFileSync(join(SRC, relative), "utf8").split("\n"); + + lines.forEach((line, index) => { + if (isComment(line)) return; + // Backticked prose inside a code line is still prose. + if (/(? { + const planted = ["const f = new Intl.DateTimeFormat(locale);"]; + + expect(planted.filter((line) => !isComment(line) && /(? Date: Sat, 12 Sep 2026 00:58:48 +0700 Subject: [PATCH 20/24] fix(ui): preserve consumer layouts and validated form output --- .github/workflows/ci.yml | 15 +- README.md | 15 +- docs/release-readiness-2026-09-12.md | 167 ++++++++++++++++++ docs/ui-usage.md | 5 +- layouts.library.json | 1 + package.json | 4 +- scripts/check-package.ts | 7 + scripts/generate-purge-manifest.ts | 17 ++ scripts/smoke-consumer.ts | 14 +- .../immersive-landing/ImmersiveLanding.css | 2 +- .../components/CookieConsent.tsx | 6 +- src/hooks/form/createForm.ts | 34 ++-- tests/ps-qa-headless/form.ron | 34 +++- tests/ps-qa/form.ron | 36 +++- tests/qa-harness/README.md | 26 +-- tests/qa-harness/components.ts | 3 +- tests/qa-harness/generate-checks.ts | 18 ++ tests/qa-harness/mount.tsx | 39 ++++ tests/qa-harness/rsbuild.config.ts | 4 +- tests/qa-harness/run-all.sh | 28 +-- 20 files changed, 392 insertions(+), 83 deletions(-) create mode 100644 docs/release-readiness-2026-09-12.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee3e8c04..64e671c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,23 +80,16 @@ jobs: run: bun run build # Native renderer outcomes replace jsdom interaction tests as the release - # gate. One headless host per component keeps the complete sweep below two - # minutes while preserving a clean document between components. + # gate. One headless host per component preserves a clean document. - name: Rendered component outcomes env: # A shared runner takes longer than a local machine for outcomes that pass # locally well inside the budget. This widens scheduling deadlines only; every # declared rendered-state transition is still required. QA_TIMEOUT_SCALE: 2 - # This runner has no font catalogue, and it should not have one: a - # component library that needs a GUI stack installed to be tested is - # a component library nobody can test. With no fonts every glyph - # shapes to nothing, so anything sized by its text lays out flat and - # every paint assertion fails for a reason that says nothing about - # the component. The headless profile asks the same questions about - # layout instead; the full profile keeps the visual half and runs - # where there are fonts. See `PROFILES` in generate-checks.ts. - QA_PROFILE: headless + # The shared host action installs fonts without a desktop stack. + # Keep actual paint assertions in the release gate. + QA_PROFILE: full # The harness looks for `chuzz-headless` on PATH; here it is the # build the composite action above just made, so it is named # outright. diff --git a/README.md b/README.md index 614ffcbd..caaeac0f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ yarn add @pathscale/ui solid-layouts && yarn add -D rsbuild-plugin-solid-layouts ## Setup -`@pathscale/ui` 2.x is a compiled Layout bundle. Configure the application compiler before +`@pathscale/ui` 3.x is a compiled Layout bundle. Configure the application compiler before Solid transforms JSX: ```ts @@ -58,11 +58,22 @@ import "@pathscale/ui/index.css"; export const Example = () => ( - + ); ``` +In the application's Tailwind v4 stylesheet, register the package as a source +(adjust the relative path from that stylesheet): + +```css +@source "../node_modules/@pathscale/ui"; +``` + +This includes the published `dist/responsive-classes.txt` scanner input. Grid and +Flex compose responsive classes at runtime, so scanning only the compiled JavaScript +misses utilities such as `md:grid-cols-3`. + ## Theming Two themes ship with the library: `light` (the default when no attribute is set) and `dark`. diff --git a/docs/release-readiness-2026-09-12.md b/docs/release-readiness-2026-09-12.md new file mode 100644 index 00000000..dc81ffa8 --- /dev/null +++ b/docs/release-readiness-2026-09-12.md @@ -0,0 +1,167 @@ +# UI release review — 12 September 2026 + +Status: **not ready to deploy**. This is the working release gate, not a sign-off. +The owner reviews the existing PRs before deployment. Do not land a branch that +automatically deploys or publishes without approval. Before creating a Fly dev +instance, contact the owner so they can be online for questions. + +## Concrete TODO + +The release script currently computes **3.2.0** from npm's 3.1.0 baseline and +the branch's conventional commits. This is a proposed release, not a published +version; CI remains responsible for assigning and publishing it after approval. + +- [x] Build the candidate ps-qa and font-enabled host; verify post-action paint, + explicit gridcell targets, keyboard shortcuts, and transformed pointer actions. +- [ ] Resolve ps-blitz CI's old-host boundary and verify the coordinated stack. +- [ ] Build and pack fresh UI source with solid-layouts 0.2.4; run the full native + component sweep, API/package gates, and clean consumer builds. +- [ ] Resolve Honey's cold first-submit failure; verify allowed and denied actions + for Platform Admin, App Admin, and Guest, including session and security flows. +- [ ] Verify Worktables editing, cancellation, undo, findings, and zoom; rerun + js.software calendar/navigation and website theme contrast with the fixed driver. +- [ ] Repeat scoped site E2E against the final package, recording missing backend + contracts separately from library regressions. +- [ ] Push verified changes to the existing PRs and reconcile their descriptions + and CI results with this evidence for owner review. +- [ ] After owner approval, release the dependency chain in order, verify registry + availability, and deploy only the approved website changes. + +## Release sequence + +1. [ps-blitz #95](https://github.com/pathscale/ps-blitz/pull/95): publish 0.4.8 + after the select accessibility and transformed geometry changes are verified. +2. [ps-observability #21](https://github.com/pathscale/ps-observability/pull/21): + publish blitz-control-protocol 0.5.0 and ps-qa 0.7.1 against that engine. +3. [tauri-runtime-blitz #57](https://github.com/pathscale/tauri-runtime-blitz/pull/57): + publish 0.4.0 against the shared protocol. +4. [chuzz #45](https://github.com/pathscale/chuzz/pull/45): shared document actions, + headless build gating, and a font-enabled website QA host. +5. [UI #289](https://github.com/pathscale/UI/pull/289): verify the packaged library + and its consumers with the released host and driver, then publish through CI. +6. Review and deploy approved website PRs using the published library. + +The older handover put tauri-runtime-blitz before ps-observability. Its manifest +requires protocol 0.5, so that order cannot resolve. Registry dependency failures +before the upstream publications are expected and are separate from regressions. + +`solid-layouts` 0.2.4 has already published through +[PR #19](https://github.com/pathscale/solid-layouts/pull/19). It forwards caller +styles to the root slot; without it, UI Card positions and dimensions are dropped. +UI and consumer manifest floors and resolvable lockfiles are updated. UI has the +published 0.2.4 installed; final clean consumer installs remain part of this gate. +Worktables' clean install still awaits the separate house DSL SDK 0.1.2 release. + +## Library and harness findings + +| Finding | Current evidence | Remaining verification | +| --- | --- | --- | +| Native option labels and selected state disappeared in the control refactor | Six regression tests restored; native select fixture passes in Linux CI and with the final local stack | Verify published protocol integration | +| Transformed client rectangles disagreed with painted controls | Four geometry tests, inline fragment tests, and full Linux suite pass; final pointer fixture and Worktables zoom/drag checks pass | CI host boundary and published integration | +| New pointer fixture runs against old chuzz in ps-blitz CI | Geometry passes; pointer action is rejected as unsupported by the host | Run the coordinated candidate stack and resolve the CI host version boundary | +| Responsive layout classes were purged from consumers | Purge manifest includes responsive Grid/Flex classes; 24x landing checks passed | Full consumer rebuilds from the final package | +| Form submission discarded schema output | Typed schema output preserved; six native form checks passed | Honey cold first-submit failure still unresolved | +| ConnectionSettings missing from layout manifest | Export added; isolated package consumer build passed | Pays runtime checks | +| CI weakened paint checks on a fontless host | Font-enabled host builds; UI CI selects full checks; fresh local sweep passes 270 checks across 75 component fixtures | Repeat website theme checks; verify Linux CI after dependency release | +| ps-qa measured contrast and other paint assertions before their declared action | Verdict reads moved after input; native regression passes both restoring and breaking contrast | Repeat website theme checks | +| ps-qa rejected explicit gridcell targets | Explicit role selectors now bypass the generic inventory role list while retaining actionability checks | Native role regression and js.software calendar rerun | +| ps-qa's older drag diagnostic only scrolls containers | Actual pointer dragging/cancellation added to CLI and declarative checks; native fixture and Worktables movement/cancellation/undo checks pass | PR and CI review | +| UI sweep could accept bundles older than the library source | Staleness guard now includes library source and package output; confirmed it rejects the current outdated bundle before launching a host | Fresh full build and sweep | + +Use `/Users/revenge/code/ps-observability/target/debug/ps-qa` (0.7.1) and +`/Users/revenge/code/chuzz/target/release/chuzz-headless` for local candidate runs. +The installed `~/.cargo/bin/ps-qa` was 0.6.3 and is not the release candidate. +`qa-hosted --checks` takes a directory. Build before running: stale built files do +not verify source changes. Keep pointer, keyboard, scroll, paint, and persistence +failures visible; do not replace them with presence checks to obtain a pass. + +## Website scope + +All local checkouts are under `/Users/revenge/code`; remotes are `pathscale/`. +Counts below are earlier observed runs, not a final release verdict. They do not +prove that every product feature is covered, and must be repeated against the +final package and runtime. Check definitions have changed since some runs. + +| Repository / existing PR | Observed result or blocker | +| --- | --- | +| [honey.id #332](https://github.com/pathscale/honey.id/pull/332) | Earlier 177-check baseline passed. Expanded application lifecycle exposes a cold first-submit failure; final three-role coverage is incomplete. | +| [worktables.dev #9](https://github.com/pathscale/worktables.dev/pull/9) | UI/SVG editor replaces Cytoscape/ELK. Fresh package run passes 112/112, including 33 designer and 7 findings checks. Real pointer movement, cancellation, undo, zoom, and emitted schema edits pass. Clean install still awaits house DSL SDK 0.1.2; final visual inspection is pending. | +| [crates.vip #1](https://github.com/pathscale/crates.vip/pull/1) | Earlier 71/71; authentication is deliberately bypassed in both client and backend, so this is not evidence of authenticated role coverage. | +| [24x.ai #11](https://github.com/pathscale/24x.ai/pull/11) | Earlier 137/141; corrected responsive landing 16/16. Auth app identity/backend setup still needs final verification. | +| [js.software #53](https://github.com/pathscale/js.software/pull/53) | Latest observed 309/316; explicit gridcell target fix and unique Layouts page marker prepared. CI now includes all declared groups. Fresh run pending. | +| [kard.vip #8](https://github.com/pathscale/kard.vip/pull/8) | Earlier 223/223; demo actions do not prove payment functionality. | +| [nofilter.io #340](https://github.com/pathscale/nofilter.io/pull/340) | Earlier 127/129; Guest login route failures and suspended dev backend. | +| [pathscale.com #17](https://github.com/pathscale/pathscale.com/pull/17) | Public site remains in scope. Owner confirmed the old portal has little value and need not block UI. Configured `pathscale-be` no longer exists; do not recreate without a reviewed deployment plan. | +| [pays.online #166](https://github.com/pathscale/pays.online/pull/166) | Package build passes after ConnectionSettings export fix. Wallet settings call methods absent from the backend schema; onboarding contains unfinished handlers. The configured Honey app id is a UUID rather than the required 16-character public id. Real payment actions require a defined dev setup and review. | +| [promptsyntax.org #18](https://github.com/pathscale/promptsyntax.org/pull/18) | Earlier 129/129; final package rerun pending. | +| [support.cafe #12](https://github.com/pathscale/support.cafe/pull/12) | Earlier 104/104; final package rerun pending. | +| [web3.trading #18](https://github.com/pathscale/web3.trading/pull/18) | Earlier 102/103 after shared scroll action fix; cookie/theme contrast remains. | +| [ui-starter-app #13](https://github.com/pathscale/ui-starter-app/pull/13) | Earlier 144/144; final package rerun pending. | +| [agencyzero #211](https://github.com/pathscale/agencyzero/pull/211), [#212](https://github.com/pathscale/agencyzero/pull/212) | UI and control integration in scope; core-specific features are handed to a dedicated owner after UI is ready. | + +Honey verification must cover **Platform Admin, App Admin, and Guest** with real +allowed and denied behavior. Application creation, saved edits, deletion, logout, +session recovery, and relevant security settings need outcomes, not just screen +presence. TOTP/recovery verification remains incomplete. Only uniquely named +disposable QA applications may be changed or deleted by the lifecycle checks. + +Honey's creation handler now awaits its mutation, so the form's submitting state +covers the backend request. This is not yet evidence that the cold-submit failure +is fixed. For that investigation, note that the native runtime reports thrown +jobs but installs no Boa promise-rejection tracker; an ignored rejected submit +promise may therefore leave no runtime diagnostic. Capture the submit rejection +directly in a temporary diagnostic build before concluding that no exception occurs. + +Pathscale restoration, if approved, should mirror crates.vip's low-cost deployment: +shared IPv4, shared CPU, one small machine. The crates backend's `fly.toml` and +`docs/deploy.md` are the reference. Do not assume the former Pathscale placeholder +callback or diagnostic dashboard is a production feature specification. + +`consulting.parcle.ai` is unmaintained, intentionally absent locally, and excluded +from this release gate. + +## Local changes and branches + +Preserve unrelated changes and append work to existing PRs. Do not recreate the +deleted scratchpad checkout or delete branches while auditing them. + +The scoped branch comparison found most apparently orphaned engine/control commits +already present as equivalent patches. Pays' remaining local work was inspected: + +- `feat/engine-console`: `6ed3396` adds a separate enforcement-engine contract, + connection, status and payment pages. Its default backend is localhost and it + has no verified deployment. The request-id control updates a signal after the + form has captured its defaults, so new-id and post-send rotation need behavioral + verification and correction before use. Preserve this feature branch; it is not + a missing UI migration fix and is not approved payment functionality. +- The following `aa6cccd` removes old theme/table dependencies; the current release + branch already contains the corresponding migration, so do not replay it blindly. +- `wip/local-save-20260815`: `c0560c1` and `5309222` contain signing design documents + and their correction. Preserve these for payment/backend review; they do not + change the shipped frontend. No remote branch contains `6ed3396` or `5309222`. + +No local branch was deleted. Any later integration belongs in the existing Pays +PR and must retain its backend and payment review requirements. +Worktables has four local commits ahead of its existing PR branch plus the editor +replacement. These changes must be reviewed and pushed together after verification. + +Two ps-blitz patch-identity exceptions were inspected: `fix/engine-gaps`' response +metadata fetch is present in the release branch with later configurable user-agent +changes, and `release/engine-fixes`' remaining unique commit only bumps the old +version to 0.3.7. Neither needs replaying onto 0.4.8. + +The original `solid-layouts` checkout contains other local work; the published root +style fix was made in `/Users/revenge/code/solid-layouts-ui-release` to preserve it. + +## Pending final run + +The shared build window completed: the font-enabled host, driver, UI package, +Honey and Worktables build successfully. UI's full native sweep passes 270 checks +across 75 fixtures; API/package checks pass across 187 components and 1,002 files. +The native gesture/paint regression and ps-qa clippy/tests pass. Worktables passes +112/112. Honey still fails its cold first application submission. + +The core task has another requested 5–10 minute measurement window; heavy local +work is held during it. Next: inspect Honey's disposable bundle with logging +injected after minification (the production optimizer removes source logging), +then finish remaining consumers and CI integration. No deployment sign-off yet. diff --git a/docs/ui-usage.md b/docs/ui-usage.md index 9db92a61..eda9d4c3 100644 --- a/docs/ui-usage.md +++ b/docs/ui-usage.md @@ -69,6 +69,8 @@ Nested glass is flattened to one pane on purpose, and both `prefers-reduced-transparency` and a browser without `backdrop-filter` fall back to a more opaque fill. +Components require `solid-layouts >=0.2.4` so caller styles reach their root elements reactively. + ## Component conventions (consumer-facing) - Booleans are HeroUI-style `is*` where they exist: `isDisabled`, `isInvalid`, @@ -363,7 +365,8 @@ const form = createForm({ - `Form` without a `form` prop = plain styled `
` (`FormRoot`). With `form` = context provider + wired submit. - Inside a ``: `useField(name)` → `{value, error, touched, invalid, handleChange, handleBlur}`. **Errors are touch-gated** — `error()` is `undefined` until the field blurs. `FormSubmitButton` disables on `!form.isValid()` (not touch-gated), so the button can be disabled with no visible error. A failed `submit()` touches every field, so the errors it refused on all become visible at once. - The form API is the library's own: `values()`, `getFieldValue`, `getFieldMeta`, `setFieldValue`, `validateField`, `submit()`, `isSubmitting()`, `isValid()`. There is no longer a `_tsForm` escape hatch, because there is no longer a wrapped library to escape to. -- Schema validation runs on change+blur+submit; blur errors clear immediately on change once valid. +- Synchronous schema validation runs on change, blur, and submit; touched errors clear when valid. Submit also awaits asynchronous Standard Schemas. +- `defaultValues` and field accessors retain the input type. `onSubmit` receives the schema’s validated output, including coercions, transforms, and defaults. Validation runs once for each submit. ## DataGrid (assembled) diff --git a/layouts.library.json b/layouts.library.json index 31e06a69..eb0958c8 100644 --- a/layouts.library.json +++ b/layouts.library.json @@ -54,6 +54,7 @@ "ComboBoxTrigger", "ComplexColorWheel", "Composer", + "ConnectionSettings", "CookieConsent", "DataGrid", "DateField", diff --git a/package.json b/package.json index 3032f448..35843f01 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "postcss-selector-parser": "^7.1.1", "rsbuild-plugin-solid-layouts": "^0.2.1", "solid-js": "2.0.0-rc.4", - "solid-layouts": "^0.2.3", + "solid-layouts": "^0.2.4", "solid-layouts-oxc": "^0.2.3", "svgo": "^3.3.3", "tailwindcss": "^4.3.3", @@ -111,7 +111,7 @@ "@solidjs/web": ">=2.0.0-rc.0", "popmotion": "^11.0.5", "solid-js": ">=2.0.0-rc.0", - "solid-layouts": "^0.2.0" + "solid-layouts": "^0.2.4" }, "peerDependenciesMeta": { "@standard-schema/spec": { diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 9555af9d..59a8936a 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -54,6 +54,13 @@ const entries = new Set( const shipped = (rel: string) => entries.has(rel.replace(/^\.\//, "")); +if (!shipped("dist/responsive-classes.txt")) { + failures.push({ + rule: "responsive CSS source is missing", + detail: "dist/responsive-classes.txt must ship so consumer Tailwind builds can discover Grid and Flex breakpoint utilities", + }); +} + /** * A wildcard target is satisfied if anything in the tarball matches its shape. * Note `*` in an `exports` target matches across path segments — it is not a diff --git a/scripts/generate-purge-manifest.ts b/scripts/generate-purge-manifest.ts index 860616c7..44b5b2c9 100644 --- a/scripts/generate-purge-manifest.ts +++ b/scripts/generate-purge-manifest.ts @@ -2,6 +2,7 @@ import { Glob } from "bun"; import { copyFile, cp, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; +import { breakpoints } from "../src/components/types"; const source = "src/components"; const temporary = await mkdtemp(join(tmpdir(), "ui-purge-")); @@ -14,6 +15,22 @@ try { for await (const relative of new Glob("**/*.recipe.ts").scan({ cwd: components })) { await copyFile(join(components, relative), join(dirname(join(components, relative)), `${basename(relative, ".recipe.ts")}.classes.ts`)); } + // Responsive prefixes are composed at runtime, so Tailwind cannot discover + // them in the compiled JS. Keep a scanner input in the published package, + // derived from the same maps and breakpoints the components actually use. + const responsiveClasses = new Set(); + for (const component of ["grid/Grid", "flex/Flex"]) { + const { CLASSES } = await import(join(components, `${component}.classes.ts`)); + for (const [prop, values] of Object.entries(CLASSES)) { + if (prop === "base") continue; + for (const value of Object.values(values as Record)) { + for (const breakpoint of breakpoints) { + responsiveClasses.add(breakpoint === "base" ? value : `${breakpoint}:${value}`); + } + } + } + } + await writeFile("dist/responsive-classes.txt", [...responsiveClasses].sort().join("\n") + "\n"); const child = Bun.spawn([ "bun", "run", diff --git a/scripts/smoke-consumer.ts b/scripts/smoke-consumer.ts index 408b9166..4067dcd3 100644 --- a/scripts/smoke-consumer.ts +++ b/scripts/smoke-consumer.ts @@ -126,6 +126,7 @@ writeFileSync( Card, ColorWheel, ComplexColorWheel, + ConnectionSettings, Dialog, Flex, Icon, @@ -136,6 +137,7 @@ writeFileSync( toast, createDataGrid, createForm, + createConnectionSettings, type Flavor, type Size, type State, @@ -147,6 +149,11 @@ import { runMotion } from "${pkgJson.name}/motion"; const flavor: Flavor = "primary"; const size: Size = "md"; const state: State = "loading"; +const connection = createConnectionSettings({ + storageKey: "consumer-connections", + endpoints: [{ name: "api", fallback: "wss://example.com" }], + onApply: () => {}, +}); export const App = () => ( @@ -162,6 +169,9 @@ export const App = () => ( onChange={() => {}} adjustments={[]} /> + ); @@ -248,11 +258,11 @@ step("publish every exercised Layout in the manifest", () => { const manifest = JSON.parse( readFileSync(join(packageRoot, installed.solidLayouts), "utf8"), ); - for (const name of ["ColorWheel", "ComplexColorWheel"]) { + for (const name of ["ColorWheel", "ComplexColorWheel", "ConnectionSettings"]) { if (!manifest.components?.[name]) throw new Error(`missing Layout manifest entry: ${name}`); } - return "ColorWheel and ComplexColorWheel are registered"; + return "ColorWheel, ComplexColorWheel and ConnectionSettings are registered"; }); step("typecheck with moduleResolution: bundler", () => run("./node_modules/.bin/tsc --noEmit", fixture), diff --git a/src/components/immersive-landing/ImmersiveLanding.css b/src/components/immersive-landing/ImmersiveLanding.css index 10404baa..91a5c099 100644 --- a/src/components/immersive-landing/ImmersiveLanding.css +++ b/src/components/immersive-landing/ImmersiveLanding.css @@ -358,7 +358,7 @@ .immersive-landing-cookie__manage-button { border: 0; background: transparent; - color: color-mix(in oklab, var(--color-base-content) 70%, transparent); + color: var(--color-base-content); font-size: 0.875rem; text-decoration: underline; cursor: pointer; diff --git a/src/components/immersive-landing/components/CookieConsent.tsx b/src/components/immersive-landing/components/CookieConsent.tsx index 08badefe..3d5836bf 100644 --- a/src/components/immersive-landing/components/CookieConsent.tsx +++ b/src/components/immersive-landing/components/CookieConsent.tsx @@ -279,13 +279,15 @@ export const CookieConsent: Component = (props) => { > {texts().decline} - +
diff --git a/src/hooks/form/createForm.ts b/src/hooks/form/createForm.ts index a97a56fd..92ca1b1f 100644 --- a/src/hooks/form/createForm.ts +++ b/src/hooks/form/createForm.ts @@ -9,7 +9,7 @@ export type AsyncValidatorFn = (context: { value: TValues; }) => Promise> | undefined>; -export type CreateFormOptions = { +export type CreateFormOptions = { /** * Initial values for every field. Used to infer the form's value type and * to determine whether a field is "dirty". @@ -20,7 +20,7 @@ export type CreateFormOptions = { * Any Standard Schema-compatible schema (Zod, Valibot, Arktype, ...). * Runs on change, on blur and on submit. */ - schema?: StandardSchemaV1; + schema?: StandardSchemaV1; /** * Additional async validators for fields that need server-side validation @@ -34,7 +34,7 @@ export type CreateFormOptions = { /** * Called when the form is submitted and all validators pass. */ - onSubmit?: (value: TValues) => void | Promise; + onSubmit?: (value: TOutput) => void | Promise; }; /** What a field tracks besides its value. */ @@ -115,8 +115,8 @@ const issuesToErrors = ( * field is touched — the display gate lives in `useField`, so a submit can * surface every error at once by touching every field. */ -export const createForm = ( - options: CreateFormOptions, +export const createForm = ( + options: CreateFormOptions, ): FormApi => { // `NoFn` rejects a callable initial value, which a generic `TValues` cannot // prove it is not. The values are a plain object by construction. @@ -200,24 +200,18 @@ export const createForm = ( }); flush(); - let ok = runSchema(); - - // An async schema is resolved here, where there is somewhere to await it. + // Validate once and keep the schema's output: transforms and defaults are + // part of validation, and the submit handler must receive their result. + let output = values as unknown as TOutput; const schema = options.schema; if (schema) { - const result = schema["~standard"].validate(values as TValues); - if (result instanceof Promise) { - const resolved = await result; - applyErrors( - resolved.issues ? issuesToErrors(resolved.issues) : {}, - true, - ); - ok = !resolved.issues?.length; - } + const pending = schema["~standard"].validate(values as TValues); + const result = pending instanceof Promise ? await pending : pending; + applyErrors(result.issues ? issuesToErrors(result.issues) : {}, true); + if (result.issues) return; + output = result.value; } - if (!ok) return; - setStatus((draft) => { draft.isSubmitting = true; }); @@ -235,7 +229,7 @@ export const createForm = ( return; } } - await options.onSubmit?.(values as TValues); + await options.onSubmit?.(output); } finally { setStatus((draft) => { draft.isSubmitting = false; diff --git a/tests/ps-qa-headless/form.ron b/tests/ps-qa-headless/form.ron index b5789d8d..a24090d4 100644 --- a/tests/ps-qa-headless/form.ron +++ b/tests/ps-qa-headless/form.ron @@ -25,13 +25,35 @@ expect: Paints, ), ( - id: "form-paints", + id: "form-refuses-invalid-input", group: "form", - what: "the Form reaches the renderer with a box", - open: None, - hover: None, - click: None, - subject: "Form", + what: "a native submit exposes validation instead of saving invalid input", + click: Some("button:Save quantity"), + subject: "alert:Enter a positive quantity", + expect: Present, + ), + ( + id: "form-invalid-submit-does-not-save", + group: "form", + what: "the invalid submission never reaches the consumer callback", + subject: "status:Not saved", expect: Present, ), + ( + id: "form-submits-transformed-value-once", + group: "form", + what: "one click submits the schema output as a number exactly once", + setup_type_into: Some("textbox:Quantity"), + setup_text: Some("42"), + click: Some("button:Save quantity"), + subject: "status:Saved 42:number:1", + expect: Present, + ), + ( + id: "form-clears-validation-after-correction", + group: "form", + what: "the corrected value clears the visible validation error", + subject: "alert:Enter a positive quantity", + expect: Absent, + ), ] diff --git a/tests/ps-qa/form.ron b/tests/ps-qa/form.ron index df8507f4..ef942714 100644 --- a/tests/ps-qa/form.ron +++ b/tests/ps-qa/form.ron @@ -25,13 +25,35 @@ expect: Paints, ), ( - id: "form-paints", + id: "form-refuses-invalid-input", group: "form", - what: "the Form reaches the renderer with a box", - open: None, - hover: None, - click: None, - subject: "Form", - expect: Paints, + what: "a native submit exposes validation instead of saving invalid input", + click: Some("button:Save quantity"), + subject: "alert:Enter a positive quantity", + expect: Present, + ), + ( + id: "form-invalid-submit-does-not-save", + group: "form", + what: "the invalid submission never reaches the consumer callback", + subject: "status:Not saved", + expect: Present, + ), + ( + id: "form-submits-transformed-value-once", + group: "form", + what: "one click submits the schema output as a number exactly once", + setup_type_into: Some("textbox:Quantity"), + setup_text: Some("42"), + click: Some("button:Save quantity"), + subject: "status:Saved 42:number:1", + expect: Present, + ), + ( + id: "form-clears-validation-after-correction", + group: "form", + what: "the corrected value clears the visible validation error", + subject: "alert:Enter a positive quantity", + expect: Absent, ), ] diff --git a/tests/qa-harness/README.md b/tests/qa-harness/README.md index c1c131a5..be79b2e0 100644 --- a/tests/qa-harness/README.md +++ b/tests/qa-harness/README.md @@ -1,8 +1,7 @@ # Native component QA -Every exported component is mounted alone and driven through Blitz's native -inspection protocol. No browser, jsdom, desktop window, or screen coordinates -are involved. +Every exported component is mounted alone in chuzz's headless browser and driven +through the shared control protocol by ps-qa. No desktop window is opened. ## Contract @@ -10,8 +9,8 @@ are involved. the semantic controls a person uses. `generate-checks.ts` turns that declaration into ps-qa outcomes under `tests/ps-qa`. -The current inventory contains 72 root components. Every component must build, -mount, and paint. Interactive components must also expose the real result of +Every inventoried component must build, mount, and paint. Interactive components +must also expose the real result of their public callback or controlled state change: - Checkbox and Switch change their selected state. @@ -36,7 +35,7 @@ valid way to make an outcome pass. bun run build bun run qa:checks bun run qa:build -zsh tests/qa-harness/run-all.sh +bash tests/qa-harness/run-all.sh ``` The host is `chuzz-headless`, a mode of chuzz: it loads through the same loader @@ -45,17 +44,20 @@ rather than a second one with the web platform missing from it. Build it from a chuzz checkout and name it: ```zsh -cargo build --release --manifest-path ../chuzz/Cargo.toml --bin chuzz-headless -QA_HOST=../chuzz/target/release/chuzz-headless zsh tests/qa-harness/run-all.sh +cargo build --release --manifest-path ../chuzz/Cargo.toml --bin chuzz-headless \ + --no-default-features --features capture,javascript,scrollbars,webp,system-fonts +QA_HOST=../chuzz/target/release/chuzz-headless bash tests/qa-harness/run-all.sh ``` -`QA_PS_QA` does the same for a local ps-qa. The script refuses stale bundles -unless `QA_ALLOW_STALE=1` is explicitly set. +`QA_PS_QA` does the same for a local ps-qa, version 0.7.1 or newer. The script +refuses stale bundles unless `QA_ALLOW_STALE=1` is explicitly set. Linux rendered +QA needs fontconfig development files and an installed font such as DejaVu; +the shared CI host action installs both. Release verification uses `QA_PROFILE=full`. The sweep uses one clean headless host per component and runs that component's outcomes in sequence. `prepare_unless` makes setup idempotent, so the same check -also runs by id against a fresh host. A complete local sweep is 72/72 in about -83 seconds. +also runs by id against a fresh host. Report the actual outcome counts and +failures from each run; inventory size alone does not establish coverage. ## Adding a component diff --git a/tests/qa-harness/components.ts b/tests/qa-harness/components.ts index 76e537a3..7fba4373 100644 --- a/tests/qa-harness/components.ts +++ b/tests/qa-harness/components.ts @@ -53,6 +53,7 @@ export type ComponentKind = | "action" | "toggle" | "field" + | "form" | "slider" | "inline-edit" | "overlay" @@ -362,7 +363,7 @@ export const COMPONENTS: ComponentSpec[] = [ }, { id: "flex", component: "Flex", kind: "display" }, { id: "footer", component: "Footer", kind: "display" }, - { id: "form", component: "Form", kind: "display" }, + { id: "form", component: "Form", kind: "form", subject: "Save quantity", subjectRole: "button" }, { id: "glow-card", component: "GlowCard", kind: "display" }, { id: "grid", component: "Grid", kind: "display" }, { id: "header", component: "Header", kind: "display" }, diff --git a/tests/qa-harness/generate-checks.ts b/tests/qa-harness/generate-checks.ts index d276f99c..6dadc505 100644 --- a/tests/qa-harness/generate-checks.ts +++ b/tests/qa-harness/generate-checks.ts @@ -836,6 +836,24 @@ function checksFor(spec: ComponentSpec, profile: Profile): string { ); } + if (spec.kind === "form") { + records.push( + check({ id: '"form-refuses-invalid-input"', group: '"form"', + what: '"a native submit exposes validation instead of saving invalid input"', + click: `Some(${subject})`, subject: '"alert:Enter a positive quantity"', expect: "Present" }), + check({ id: '"form-invalid-submit-does-not-save"', group: '"form"', + what: '"the invalid submission never reaches the consumer callback"', + subject: '"status:Not saved"', expect: "Present" }), + check({ id: '"form-submits-transformed-value-once"', group: '"form"', + what: '"one click submits the schema output as a number exactly once"', + setup_type_into: 'Some("textbox:Quantity")', setup_text: 'Some("42")', + click: `Some(${subject})`, subject: '"status:Saved 42:number:1"', expect: "Present" }), + check({ id: '"form-clears-validation-after-correction"', group: '"form"', + what: '"the corrected value clears the visible validation error"', + subject: '"alert:Enter a positive quantity"', expect: "Absent" }), + ); + } + if (spec.kind === "display") { /* * A `display` component is the one kind allowed to have no `subject`: the diff --git a/tests/qa-harness/mount.tsx b/tests/qa-harness/mount.tsx index d28bcf2e..f6cfbd08 100644 --- a/tests/qa-harness/mount.tsx +++ b/tests/qa-harness/mount.tsx @@ -35,6 +35,10 @@ import InlineEdit from "@pathscale/ui/components/inline-edit"; import Popover from "@pathscale/ui/components/popover"; import Select from "@pathscale/ui/components/select"; import Tabs from "@pathscale/ui/components/tabs"; +import Button from "@pathscale/ui/components/button"; +import { Form } from "@pathscale/ui/components/form"; +import Input from "@pathscale/ui/components/input"; +import { createForm } from "@pathscale/ui/hooks/form"; import { createErrorBoundary, createSignal, For, Show } from "solid-js"; import { Dynamic, type JSX, render } from "@solidjs/web"; import { COMPONENTS, type ComponentSpec } from "./components"; @@ -769,6 +773,40 @@ function DockFixture(props: { spec: ComponentSpec; under?: unknown }) { ); } +function FormFixture() { + const [saved, setSaved] = createSignal("Not saved"); + let submissions = 0; + const form = createForm<{ quantity: string }, { quantity: number }>({ + defaultValues: { quantity: "" }, + schema: { + "~standard": { + version: 1, + vendor: "qa-quantity", + validate(value) { + const quantity = Number((value as { quantity: string }).quantity); + return Number.isInteger(quantity) && quantity > 0 + ? { value: { quantity } } + : { issues: [{ message: "Enter a positive quantity", path: ["quantity"] }] }; + }, + }, + }, + onSubmit(value) { + setSaved(`Saved ${value.quantity}:${typeof value.quantity}:${++submissions}`); + }, + }); + return ( + + form.setFieldValue("quantity", event.currentTarget.value)} /> + + {(message) =>

{message}

} +
+ +

{saved()}

+ + ); +} + /** Ids with a hand-written fixture; everything else mounts generically. */ const FIXTURES: Record< string, @@ -788,6 +826,7 @@ const FIXTURES: Record< dialog: DialogFixture, dock: DockFixture, dropdown: DropdownFixture, + form: FormFixture, "inline-edit": InlineEditFixture, input: FieldFixture, "language-switcher": LanguageSwitcherFixture, diff --git a/tests/qa-harness/rsbuild.config.ts b/tests/qa-harness/rsbuild.config.ts index 5beb99de..dfdba023 100644 --- a/tests/qa-harness/rsbuild.config.ts +++ b/tests/qa-harness/rsbuild.config.ts @@ -2,8 +2,8 @@ * Build config for the QA harness. * * Separate from `rslib.config.ts`, which builds the library: this is an - * application, and it consumes the library from source so a check runs against - * the working tree rather than the last publish. + * application, and it consumes the freshly built library package so a check + * runs against the working tree rather than the last publish. */ import { defineConfig } from "@rsbuild/core"; import { pluginBabel } from "@rsbuild/plugin-babel"; diff --git a/tests/qa-harness/run-all.sh b/tests/qa-harness/run-all.sh index 8ee0d518..f74dd400 100755 --- a/tests/qa-harness/run-all.sh +++ b/tests/qa-harness/run-all.sh @@ -65,9 +65,8 @@ fi # ignored entirely. Measured: three components "failed" against a driver eight # days stale, and all three passed the moment the current one ran. # -# 0.6.3 is the floor because it is the first that reads `QA_TIMEOUT_SCALE` and -# the first that takes the descriptor to be the first line that looks like one. -readonly PS_QA_FLOOR="0.6.3" +# The driver and host use the shared control protocol from this release. +readonly PS_QA_FLOOR="0.7.1" ps_qa_version="$("$PS_QA" --version 2>/dev/null | awk '{ print $2 }')" if [[ -z "$ps_qa_version" ]]; then echo "$PS_QA does not report a version; it is too old to sweep with" >&2 @@ -122,10 +121,13 @@ fi # `\( ... \)` around the alternation: without the group, `-newer` binds to the # last `-o` branch alone, so a stale `.tsx` was never reported and the guard # passed on exactly the file it exists to catch. -# Only what the bundle is built from. The generators run outside the build and -# never reach a page, so editing one was reported as a stale bundle and blocked -# the sweep for no reason. -newest_source="$(find "$HERE" \( -name '*.tsx' -o -name '*.ts' -o -name '*.css' \) -newer "$reference" 2>/dev/null | grep -vE 'entries/|/dist/|/node_modules/|generate-.*\.ts$|rsbuild\.config\.ts$' | head -1)" +# Include the library source and compiled package: checking only the fixture +# let a component edit or library rebuild pass against an older harness bundle. +# The generators execute outside the page; their generated entries are inputs. +newest_source="$(find "$ROOT/src" "$ROOT/dist" "$HERE" \ + \( -path "$HERE/dist" -o -name node_modules \) -prune -o \ + -type f \( -name '*.tsx' -o -name '*.ts' -o -name '*.js' -o -name '*.css' \) \ + -newer "$reference" -print 2>/dev/null | grep -vE '/generate-[^/]*\.ts$' | head -1)" if [[ -n "$newest_source" ]]; then echo "the build is older than $newest_source" >&2 echo "run: bun run qa:build (or set QA_ALLOW_STALE=1 to sweep anyway)" >&2 @@ -153,13 +155,11 @@ readonly TIMEOUT_SCALE="${QA_TIMEOUT_SCALE:-1}" # Which set of checks, and what the host running them can be asked. # -# `full` is the library's contract and needs a font catalogue: macOS, and a -# contributor's machine. `headless` is the same checks with every assertion -# about paint weakened to one about layout, which is what a Linux CI runner can -# answer honestly -- with no fonts every glyph shapes to nothing, so anything -# sized by its text lays out flat and `Paints` fails for a reason that says -# nothing about the component. Both sets are generated by `qa:checks`; see the -# `PROFILES` table in `generate-checks.ts` for exactly what differs. +# `full` is the release contract on both local machines and Linux CI. Build +# the host with system-fonts and install fonts to verify actual painted text. +# The reduced `headless` profile is diagnostic coverage for fontless embedders; +# its weaker layout checks cannot establish release readiness. Both profiles +# run without a window. See `PROFILES` in generate-checks.ts for their differences. # # The flag travels with the directory on purpose. They are two halves of one # decision, and running headless checks against a strict target (or the reverse) From c5a9395a029a1e1a33b87d97825b6f20cd40b48d Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 01:11:58 +0700 Subject: [PATCH 21/24] fix(calendar): update existing cells when selection changes --- docs/release-readiness-2026-09-12.md | 41 ++++++++-------- src/components/calendar/Calendar.layout.tsx | 54 ++++++++++----------- tests/ps-qa-headless/calendar.ron | 33 ++++++++++--- tests/ps-qa/calendar.ron | 35 ++++++++++--- tests/qa-harness/components.ts | 5 +- tests/qa-harness/generate-checks.ts | 19 ++++++++ tests/qa-harness/mount.tsx | 12 +++++ 7 files changed, 139 insertions(+), 60 deletions(-) diff --git a/docs/release-readiness-2026-09-12.md b/docs/release-readiness-2026-09-12.md index dc81ffa8..302c20e6 100644 --- a/docs/release-readiness-2026-09-12.md +++ b/docs/release-readiness-2026-09-12.md @@ -13,13 +13,16 @@ version; CI remains responsible for assigning and publishing it after approval. - [x] Build the candidate ps-qa and font-enabled host; verify post-action paint, explicit gridcell targets, keyboard shortcuts, and transformed pointer actions. -- [ ] Resolve ps-blitz CI's old-host boundary and verify the coordinated stack. +- [ ] Verify ps-blitz CI with the pinned coordinated stack. The follow-up fix + excludes nested dependency checkouts from the engine workspace; local Cargo + resolution passes, and the new CI run must confirm it. - [ ] Build and pack fresh UI source with solid-layouts 0.2.4; run the full native component sweep, API/package gates, and clean consumer builds. - [ ] Resolve Honey's cold first-submit failure; verify allowed and denied actions for Platform Admin, App Admin, and Guest, including session and security flows. -- [ ] Verify Worktables editing, cancellation, undo, findings, and zoom; rerun - js.software calendar/navigation and website theme contrast with the fixed driver. +- [x] Verify Worktables editing, cancellation, undo, findings, and zoom (112/112). +- [ ] Confirm the Calendar fix in js.software; resolve Web3 theme contrast and + carousel settling failures with the fixed driver. - [ ] Repeat scoped site E2E against the final package, recording missing backend contracts separately from library regressions. - [ ] Push verified changes to the existing PRs and reconcile their descriptions @@ -58,11 +61,12 @@ Worktables' clean install still awaits the separate house DSL SDK 0.1.2 release. | --- | --- | --- | | Native option labels and selected state disappeared in the control refactor | Six regression tests restored; native select fixture passes in Linux CI and with the final local stack | Verify published protocol integration | | Transformed client rectangles disagreed with painted controls | Four geometry tests, inline fragment tests, and full Linux suite pass; final pointer fixture and Worktables zoom/drag checks pass | CI host boundary and published integration | -| New pointer fixture runs against old chuzz in ps-blitz CI | Geometry passes; pointer action is rejected as unsupported by the host | Run the coordinated candidate stack and resolve the CI host version boundary | +| CI needs the coordinated unpublished runtime stack | Exact dependency revisions pinned. The next run exposed nested Cargo workspace inheritance; exclusions fix local dependency resolution | Verify the follow-up CI run | +| Calendar cells captured selection state once | Cell state is now reactive; six native checks verify selection changes and controlled callbacks in both directions | Confirm the packed fix in js.software | | Responsive layout classes were purged from consumers | Purge manifest includes responsive Grid/Flex classes; 24x landing checks passed | Full consumer rebuilds from the final package | | Form submission discarded schema output | Typed schema output preserved; six native form checks passed | Honey cold first-submit failure still unresolved | | ConnectionSettings missing from layout manifest | Export added; isolated package consumer build passed | Pays runtime checks | -| CI weakened paint checks on a fontless host | Font-enabled host builds; UI CI selects full checks; fresh local sweep passes 270 checks across 75 component fixtures | Repeat website theme checks; verify Linux CI after dependency release | +| CI weakened paint checks on a fontless host | Font-enabled host builds; UI CI selects full checks; fresh local sweep passes 273 checks across 75 component fixtures | Repeat website theme checks; verify Linux CI after dependency release | | ps-qa measured contrast and other paint assertions before their declared action | Verdict reads moved after input; native regression passes both restoring and breaking contrast | Repeat website theme checks | | ps-qa rejected explicit gridcell targets | Explicit role selectors now bypass the generic inventory role list while retaining actionability checks | Native role regression and js.software calendar rerun | | ps-qa's older drag diagnostic only scrolls containers | Actual pointer dragging/cancellation added to CLI and declarative checks; native fixture and Worktables movement/cancellation/undo checks pass | PR and CI review | @@ -88,14 +92,14 @@ final package and runtime. Check definitions have changed since some runs. | [worktables.dev #9](https://github.com/pathscale/worktables.dev/pull/9) | UI/SVG editor replaces Cytoscape/ELK. Fresh package run passes 112/112, including 33 designer and 7 findings checks. Real pointer movement, cancellation, undo, zoom, and emitted schema edits pass. Clean install still awaits house DSL SDK 0.1.2; final visual inspection is pending. | | [crates.vip #1](https://github.com/pathscale/crates.vip/pull/1) | Earlier 71/71; authentication is deliberately bypassed in both client and backend, so this is not evidence of authenticated role coverage. | | [24x.ai #11](https://github.com/pathscale/24x.ai/pull/11) | Earlier 137/141; corrected responsive landing 16/16. Auth app identity/backend setup still needs final verification. | -| [js.software #53](https://github.com/pathscale/js.software/pull/53) | Latest observed 309/316; explicit gridcell target fix and unique Layouts page marker prepared. CI now includes all declared groups. Fresh run pending. | +| [js.software #53](https://github.com/pathscale/js.software/pull/53) | Latest completed run 315/316; remaining failure exposed UI Calendar's stale selection state. Packed Calendar fix is being verified. CI includes all declared groups. | | [kard.vip #8](https://github.com/pathscale/kard.vip/pull/8) | Earlier 223/223; demo actions do not prove payment functionality. | | [nofilter.io #340](https://github.com/pathscale/nofilter.io/pull/340) | Earlier 127/129; Guest login route failures and suspended dev backend. | | [pathscale.com #17](https://github.com/pathscale/pathscale.com/pull/17) | Public site remains in scope. Owner confirmed the old portal has little value and need not block UI. Configured `pathscale-be` no longer exists; do not recreate without a reviewed deployment plan. | | [pays.online #166](https://github.com/pathscale/pays.online/pull/166) | Package build passes after ConnectionSettings export fix. Wallet settings call methods absent from the backend schema; onboarding contains unfinished handlers. The configured Honey app id is a UUID rather than the required 16-character public id. Real payment actions require a defined dev setup and review. | | [promptsyntax.org #18](https://github.com/pathscale/promptsyntax.org/pull/18) | Earlier 129/129; final package rerun pending. | | [support.cafe #12](https://github.com/pathscale/support.cafe/pull/12) | Earlier 104/104; final package rerun pending. | -| [web3.trading #18](https://github.com/pathscale/web3.trading/pull/18) | Earlier 102/103 after shared scroll action fix; cookie/theme contrast remains. | +| [web3.trading #18](https://github.com/pathscale/web3.trading/pull/18) | Latest run 101/103: dark-theme GET STARTED contrast measured 1.96:1, and the final carousel slide did not remain stable within its outcome window. Both need investigation. | | [ui-starter-app #13](https://github.com/pathscale/ui-starter-app/pull/13) | Earlier 144/144; final package rerun pending. | | [agencyzero #211](https://github.com/pathscale/agencyzero/pull/211), [#212](https://github.com/pathscale/agencyzero/pull/212) | UI and control integration in scope; core-specific features are handed to a dedicated owner after UI is ready. | @@ -107,10 +111,10 @@ disposable QA applications may be changed or deleted by the lifecycle checks. Honey's creation handler now awaits its mutation, so the form's submitting state covers the backend request. This is not yet evidence that the cold-submit failure -is fixed. For that investigation, note that the native runtime reports thrown -jobs but installs no Boa promise-rejection tracker; an ignored rejected submit -promise may therefore leave no runtime diagnostic. Capture the submit rejection -directly in a temporary diagnostic build before concluding that no exception occurs. +is fixed. Disposable-bundle tracing confirms validation completes, the mutation +starts, and execution waits at CreateApp's RPC. One diagnostic lifecycle passed +15/15, but three consecutive repetitions stalled at that RPC. Request/response +tracing is now the next diagnostic step; no stable fix has been established. Pathscale restoration, if approved, should mirror crates.vip's low-cost deployment: shared IPv4, shared CPU, one small machine. The crates backend's `fly.toml` and @@ -142,8 +146,8 @@ already present as equivalent patches. Pays' remaining local work was inspected: No local branch was deleted. Any later integration belongs in the existing Pays PR and must retain its backend and payment review requirements. -Worktables has four local commits ahead of its existing PR branch plus the editor -replacement. These changes must be reviewed and pushed together after verification. +Worktables' four previously local commits and the verified editor replacement are +now pushed to its existing PR. They remain subject to owner review. Two ps-blitz patch-identity exceptions were inspected: `fix/engine-gaps`' response metadata fetch is present in the release branch with later configurable user-agent @@ -155,13 +159,12 @@ style fix was made in `/Users/revenge/code/solid-layouts-ui-release` to preserve ## Pending final run -The shared build window completed: the font-enabled host, driver, UI package, -Honey and Worktables build successfully. UI's full native sweep passes 270 checks +The font-enabled host, driver, UI package, Honey and Worktables build successfully. +UI's full native sweep after the Calendar fix passes 273 checks across 75 fixtures; API/package checks pass across 187 components and 1,002 files. The native gesture/paint regression and ps-qa clippy/tests pass. Worktables passes 112/112. Honey still fails its cold first application submission. -The core task has another requested 5–10 minute measurement window; heavy local -work is held during it. Next: inspect Honey's disposable bundle with logging -injected after minification (the production optimizer removes source logging), -then finish remaining consumers and CI integration. No deployment sign-off yet. +The current shared build/test window is open. Continue Honey request/response +tracing, consumer verification, and CI integration, coordinating the next quiet +measurement window with the core task. No deployment sign-off yet. diff --git a/src/components/calendar/Calendar.layout.tsx b/src/components/calendar/Calendar.layout.tsx index a9facc1e..3ff56ac9 100644 --- a/src/components/calendar/Calendar.layout.tsx +++ b/src/components/calendar/Calendar.layout.tsx @@ -420,13 +420,13 @@ const Calendar: Layout = () => {
{(date) => { - const cellState = calendarState.getCellState(date); + const cellState = createMemo(() => calendarState.getCellState(date)); const isoDate = toISODate(date); return (
= () => { type="button" {...{ class: twMerge( CLASSES.Cell.base, - cellState.isSelected && CLASSES.Cell.flag.selected, - cellState.isRangeStart && CLASSES.Cell.flag.rangeStart, - cellState.isRangeEnd && CLASSES.Cell.flag.rangeEnd, - cellState.isInCommittedRange && CLASSES.Cell.flag.inRange, - cellState.isInPreviewRange && - !cellState.isInCommittedRange && + cellState().isSelected && CLASSES.Cell.flag.selected, + cellState().isRangeStart && CLASSES.Cell.flag.rangeStart, + cellState().isRangeEnd && CLASSES.Cell.flag.rangeEnd, + cellState().isInCommittedRange && CLASSES.Cell.flag.inRange, + cellState().isInPreviewRange && + !cellState().isInCommittedRange && CLASSES.Cell.flag.inPreviewRange, - cellState.isToday && CLASSES.Cell.flag.today, - cellState.isOutsideMonth && CLASSES.Cell.flag.outsideMonth, - cellState.isDisabled && CLASSES.Cell.flag.disabled, - cellState.isUnavailable && CLASSES.Cell.flag.unavailable, - cellState.isFocused && CLASSES.Cell.flag.focused, + cellState().isToday && CLASSES.Cell.flag.today, + cellState().isOutsideMonth && CLASSES.Cell.flag.outsideMonth, + cellState().isDisabled && CLASSES.Cell.flag.disabled, + cellState().isUnavailable && CLASSES.Cell.flag.unavailable, + cellState().isFocused && CLASSES.Cell.flag.focused, ) }} data-slot="calendar-cell" data-date={isoDate} - data-selected={cellState.isSelected ? "true" : "false"} - data-range-start={cellState.isRangeStart ? "true" : "false"} - data-range-end={cellState.isRangeEnd ? "true" : "false"} - data-in-range={cellState.isInCommittedRange ? "true" : "false"} + data-selected={cellState().isSelected ? "true" : "false"} + data-range-start={cellState().isRangeStart ? "true" : "false"} + data-range-end={cellState().isRangeEnd ? "true" : "false"} + data-in-range={cellState().isInCommittedRange ? "true" : "false"} data-in-preview-range={ - cellState.isInPreviewRange ? "true" : "false" + cellState().isInPreviewRange ? "true" : "false" } - data-today={cellState.isToday ? "true" : "false"} - data-outside-month={cellState.isOutsideMonth ? "true" : "false"} - data-disabled={cellState.isDisabled ? "true" : "false"} - data-unavailable={cellState.isUnavailable ? "true" : "false"} + data-today={cellState().isToday ? "true" : "false"} + data-outside-month={cellState().isOutsideMonth ? "true" : "false"} + data-disabled={cellState().isDisabled ? "true" : "false"} + data-unavailable={cellState().isUnavailable ? "true" : "false"} role="gridcell" aria-label={calendarState.formatDayLabel(date)} - aria-selected={cellState.isAriaSelected ? "true" : "false"} - aria-disabled={cellState.isDisabled ? "true" : "false"} - disabled={cellState.isDisabled} - tabindex={cellState.isFocused ? 0 : -1} + aria-selected={cellState().isAriaSelected ? "true" : "false"} + aria-disabled={cellState().isDisabled ? "true" : "false"} + disabled={cellState().isDisabled} + tabindex={cellState().isFocused ? 0 : -1} onClick={() => selectDate(date)} onFocus={() => navigation.setFocusedDate(date)} onMouseEnter={() => { - if (cellState.isDisabled) return; + if (cellState().isDisabled) return; props.onDayHover?.(date); }} onKeyDown={handleCellKeyDown} diff --git a/tests/ps-qa-headless/calendar.ron b/tests/ps-qa-headless/calendar.ron index d745512b..6bcf203a 100644 --- a/tests/ps-qa-headless/calendar.ron +++ b/tests/ps-qa-headless/calendar.ron @@ -25,13 +25,34 @@ expect: Paints, ), ( - id: "calendar-paints", + id: "calendar-selects-existing-cell", group: "calendar", - what: "the Calendar reaches the renderer with a box", - open: None, - hover: None, - click: None, - subject: "Calendar", + what: "selecting a date updates the existing grid cell", + prepare: Some("gridcell:Sunday, June 15, 2025"), + click: Some("gridcell:Tuesday, June 24, 2025"), + subject: "gridcell:Tuesday, June 24, 2025", + expect: SelectionChanges, + ), + ( + id: "calendar-delivers-selected-value", + group: "calendar", + what: "the controlled consumer receives the selected date", + subject: "status:Selected 2025-06-24", + expect: Present, + ), + ( + id: "calendar-reselects-original-date", + group: "calendar", + what: "another selection updates the original cell again", + click: Some("gridcell:Sunday, June 15, 2025"), + subject: "gridcell:Sunday, June 15, 2025", + expect: SelectionChanges, + ), + ( + id: "calendar-delivers-restored-value", + group: "calendar", + what: "the controlled consumer receives the restored date", + subject: "status:Selected 2025-06-15", expect: Present, ), ] diff --git a/tests/ps-qa/calendar.ron b/tests/ps-qa/calendar.ron index e6d47784..af5975da 100644 --- a/tests/ps-qa/calendar.ron +++ b/tests/ps-qa/calendar.ron @@ -25,13 +25,34 @@ expect: Paints, ), ( - id: "calendar-paints", + id: "calendar-selects-existing-cell", group: "calendar", - what: "the Calendar reaches the renderer with a box", - open: None, - hover: None, - click: None, - subject: "Calendar", - expect: Paints, + what: "selecting a date updates the existing grid cell", + prepare: Some("gridcell:Sunday, June 15, 2025"), + click: Some("gridcell:Tuesday, June 24, 2025"), + subject: "gridcell:Tuesday, June 24, 2025", + expect: SelectionChanges, + ), + ( + id: "calendar-delivers-selected-value", + group: "calendar", + what: "the controlled consumer receives the selected date", + subject: "status:Selected 2025-06-24", + expect: Present, + ), + ( + id: "calendar-reselects-original-date", + group: "calendar", + what: "another selection updates the original cell again", + click: Some("gridcell:Sunday, June 15, 2025"), + subject: "gridcell:Sunday, June 15, 2025", + expect: SelectionChanges, + ), + ( + id: "calendar-delivers-restored-value", + group: "calendar", + what: "the controlled consumer receives the restored date", + subject: "status:Selected 2025-06-15", + expect: Present, ), ] diff --git a/tests/qa-harness/components.ts b/tests/qa-harness/components.ts index 7fba4373..ee4ef0b4 100644 --- a/tests/qa-harness/components.ts +++ b/tests/qa-harness/components.ts @@ -54,6 +54,7 @@ export type ComponentKind = | "toggle" | "field" | "form" + | "calendar" | "slider" | "inline-edit" | "overlay" @@ -175,7 +176,9 @@ export const COMPONENTS: ComponentSpec[] = [ { id: "calendar", component: "Calendar", - kind: "display", + kind: "calendar", + subject: "Tuesday, June 24, 2025", + subjectRole: "gridcell", }, { id: "card", component: "Card", kind: "display" }, { id: "chat-bubble", component: "ChatBubble", kind: "display" }, diff --git a/tests/qa-harness/generate-checks.ts b/tests/qa-harness/generate-checks.ts index 6dadc505..6923e6da 100644 --- a/tests/qa-harness/generate-checks.ts +++ b/tests/qa-harness/generate-checks.ts @@ -836,6 +836,25 @@ function checksFor(spec: ComponentSpec, profile: Profile): string { ); } + if (spec.kind === "calendar") { + records.push( + check({ id: '"calendar-selects-existing-cell"', group: '"calendar"', + what: '"selecting a date updates the existing grid cell"', + prepare: 'Some("gridcell:Sunday, June 15, 2025")', + click: `Some(${subject})`, subject, expect: "SelectionChanges" }), + check({ id: '"calendar-delivers-selected-value"', group: '"calendar"', + what: '"the controlled consumer receives the selected date"', + subject: '"status:Selected 2025-06-24"', expect: "Present" }), + check({ id: '"calendar-reselects-original-date"', group: '"calendar"', + what: '"another selection updates the original cell again"', + click: 'Some("gridcell:Sunday, June 15, 2025")', + subject: '"gridcell:Sunday, June 15, 2025"', expect: "SelectionChanges" }), + check({ id: '"calendar-delivers-restored-value"', group: '"calendar"', + what: '"the controlled consumer receives the restored date"', + subject: '"status:Selected 2025-06-15"', expect: "Present" }), + ); + } + if (spec.kind === "form") { records.push( check({ id: '"form-refuses-invalid-input"', group: '"form"', diff --git a/tests/qa-harness/mount.tsx b/tests/qa-harness/mount.tsx index f6cfbd08..2dda8cd1 100644 --- a/tests/qa-harness/mount.tsx +++ b/tests/qa-harness/mount.tsx @@ -36,6 +36,7 @@ import Popover from "@pathscale/ui/components/popover"; import Select from "@pathscale/ui/components/select"; import Tabs from "@pathscale/ui/components/tabs"; import Button from "@pathscale/ui/components/button"; +import Calendar from "@pathscale/ui/components/calendar"; import { Form } from "@pathscale/ui/components/form"; import Input from "@pathscale/ui/components/input"; import { createForm } from "@pathscale/ui/hooks/form"; @@ -807,6 +808,16 @@ function FormFixture() { ); } +function CalendarFixture() { + const [value, setValue] = createSignal(new Date(2025, 5, 15)); + return ( + <> + +

Selected {value().getFullYear()}-{String(value().getMonth() + 1).padStart(2, "0")}-{String(value().getDate()).padStart(2, "0")}

+ + ); +} + /** Ids with a hand-written fixture; everything else mounts generically. */ const FIXTURES: Record< string, @@ -818,6 +829,7 @@ const FIXTURES: Record< > = { "auth-submit-button": ActionFixture, button: ActionFixture, + calendar: CalendarFixture, checkbox: ToggleFixtureWithReport, collapsible: CollapsibleFixture, "connection-settings": ConnectionSettingsFixture, From df0406bde3552d8bf16a399592a9b238cfa69db6 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 01:23:26 +0700 Subject: [PATCH 22/24] docs: record verified recovery and coordinated release progress --- docs/release-readiness-2026-09-12.md | 52 +++++++++++++++------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/docs/release-readiness-2026-09-12.md b/docs/release-readiness-2026-09-12.md index 302c20e6..e12bc2dc 100644 --- a/docs/release-readiness-2026-09-12.md +++ b/docs/release-readiness-2026-09-12.md @@ -13,16 +13,19 @@ version; CI remains responsible for assigning and publishing it after approval. - [x] Build the candidate ps-qa and font-enabled host; verify post-action paint, explicit gridcell targets, keyboard shortcuts, and transformed pointer actions. -- [ ] Verify ps-blitz CI with the pinned coordinated stack. The follow-up fix - excludes nested dependency checkouts from the engine workspace; local Cargo - resolution passes, and the new CI run must confirm it. +- [x] Verify ps-blitz CI with the pinned coordinated stack. Run 34631808347 + passes after excluding nested dependency workspaces. A subsequent pin refresh + includes the newly verified WebSocket and contrast-settling fixes. - [ ] Build and pack fresh UI source with solid-layouts 0.2.4; run the full native component sweep, API/package gates, and clean consumer builds. -- [ ] Resolve Honey's cold first-submit failure; verify allowed and denied actions - for Platform Admin, App Admin, and Guest, including session and security flows. +- [x] Resolve Honey's cold first-submit failure and run the 192-check suite, + including Platform Admin, App Admin, Guest, and password change/restore. +- [ ] Finish reproducible recovery-code coverage; TOTP confirmation and Telegram + enrollment/login remain separate security gates. - [x] Verify Worktables editing, cancellation, undo, findings, and zoom (112/112). -- [ ] Confirm the Calendar fix in js.software; resolve Web3 theme contrast and - carousel settling failures with the fixed driver. +- [x] Confirm the packed Calendar fix in js.software (316/316). +- [ ] Resolve Web3 carousel settling. Its theme group now passes 18/18 after + ps-qa learned to wait for contrast within the declared outcome window. - [ ] Repeat scoped site E2E against the final package, recording missing backend contracts separately from library regressions. - [ ] Push verified changes to the existing PRs and reconcile their descriptions @@ -61,13 +64,14 @@ Worktables' clean install still awaits the separate house DSL SDK 0.1.2 release. | --- | --- | --- | | Native option labels and selected state disappeared in the control refactor | Six regression tests restored; native select fixture passes in Linux CI and with the final local stack | Verify published protocol integration | | Transformed client rectangles disagreed with painted controls | Four geometry tests, inline fragment tests, and full Linux suite pass; final pointer fixture and Worktables zoom/drag checks pass | CI host boundary and published integration | -| CI needs the coordinated unpublished runtime stack | Exact dependency revisions pinned. The next run exposed nested Cargo workspace inheritance; exclusions fix local dependency resolution | Verify the follow-up CI run | -| Calendar cells captured selection state once | Cell state is now reactive; six native checks verify selection changes and controlled callbacks in both directions | Confirm the packed fix in js.software | +| CI needs the coordinated unpublished runtime stack | Exact dependency revisions pinned; nested workspace exclusions resolved Cargo inheritance. Coordinated CI run 34631808347 passes | Verify refreshed socket/contrast pins and registry integration | +| Calendar cells captured selection state once | Cell state is now reactive; six native checks verify selection changes and controlled callbacks in both directions. Packed js.software run passes 316/316 | Owner review | +| Honey CreateApp stalled during socket connection | chuzz iterated a live listener array; the first RPC removed its open listener and skipped the next. Snapshot dispatch fixes three consecutive 15-step lifecycles and the full 192-check suite | Refreshed host CI | | Responsive layout classes were purged from consumers | Purge manifest includes responsive Grid/Flex classes; 24x landing checks passed | Full consumer rebuilds from the final package | -| Form submission discarded schema output | Typed schema output preserved; six native form checks passed | Honey cold first-submit failure still unresolved | +| Form submission discarded schema output | Typed schema output preserved; six native form checks and Honey's expanded suite pass | Owner review | | ConnectionSettings missing from layout manifest | Export added; isolated package consumer build passed | Pays runtime checks | | CI weakened paint checks on a fontless host | Font-enabled host builds; UI CI selects full checks; fresh local sweep passes 273 checks across 75 component fixtures | Repeat website theme checks; verify Linux CI after dependency release | -| ps-qa measured contrast and other paint assertions before their declared action | Verdict reads moved after input; native regression passes both restoring and breaking contrast | Repeat website theme checks | +| ps-qa measured paint before input, then sampled contrast transitions too early | Reads moved after input; contrast honors the outcome and stability windows. Six native driver scenarios pass, including delayed repair and persistent contrast failure. Web3 theme group passes 18/18 | Final stack sweeps | | ps-qa rejected explicit gridcell targets | Explicit role selectors now bypass the generic inventory role list while retaining actionability checks | Native role regression and js.software calendar rerun | | ps-qa's older drag diagnostic only scrolls containers | Actual pointer dragging/cancellation added to CLI and declarative checks; native fixture and Worktables movement/cancellation/undo checks pass | PR and CI review | | UI sweep could accept bundles older than the library source | Staleness guard now includes library source and package output; confirmed it rejects the current outdated bundle before launching a host | Fresh full build and sweep | @@ -88,18 +92,18 @@ final package and runtime. Check definitions have changed since some runs. | Repository / existing PR | Observed result or blocker | | --- | --- | -| [honey.id #332](https://github.com/pathscale/honey.id/pull/332) | Earlier 177-check baseline passed. Expanded application lifecycle exposes a cold first-submit failure; final three-role coverage is incomplete. | +| [honey.id #332](https://github.com/pathscale/honey.id/pull/332) | Expanded suite passes 192/192; three standalone app lifecycles pass consecutively. Recovery-code generation, two login/rotation cycles, old-code rejection, save gates, and unchanged-password login passed hands-on. Repeatable recovery runner is being finalized; TOTP and Telegram remain unverified. | | [worktables.dev #9](https://github.com/pathscale/worktables.dev/pull/9) | UI/SVG editor replaces Cytoscape/ELK. Fresh package run passes 112/112, including 33 designer and 7 findings checks. Real pointer movement, cancellation, undo, zoom, and emitted schema edits pass. Clean install still awaits house DSL SDK 0.1.2; final visual inspection is pending. | | [crates.vip #1](https://github.com/pathscale/crates.vip/pull/1) | Earlier 71/71; authentication is deliberately bypassed in both client and backend, so this is not evidence of authenticated role coverage. | | [24x.ai #11](https://github.com/pathscale/24x.ai/pull/11) | Earlier 137/141; corrected responsive landing 16/16. Auth app identity/backend setup still needs final verification. | -| [js.software #53](https://github.com/pathscale/js.software/pull/53) | Latest completed run 315/316; remaining failure exposed UI Calendar's stale selection state. Packed Calendar fix is being verified. CI includes all declared groups. | +| [js.software #53](https://github.com/pathscale/js.software/pull/53) | Packed Calendar fix passes 316/316. CI includes all declared groups. | | [kard.vip #8](https://github.com/pathscale/kard.vip/pull/8) | Earlier 223/223; demo actions do not prove payment functionality. | | [nofilter.io #340](https://github.com/pathscale/nofilter.io/pull/340) | Earlier 127/129; Guest login route failures and suspended dev backend. | | [pathscale.com #17](https://github.com/pathscale/pathscale.com/pull/17) | Public site remains in scope. Owner confirmed the old portal has little value and need not block UI. Configured `pathscale-be` no longer exists; do not recreate without a reviewed deployment plan. | | [pays.online #166](https://github.com/pathscale/pays.online/pull/166) | Package build passes after ConnectionSettings export fix. Wallet settings call methods absent from the backend schema; onboarding contains unfinished handlers. The configured Honey app id is a UUID rather than the required 16-character public id. Real payment actions require a defined dev setup and review. | | [promptsyntax.org #18](https://github.com/pathscale/promptsyntax.org/pull/18) | Earlier 129/129; final package rerun pending. | | [support.cafe #12](https://github.com/pathscale/support.cafe/pull/12) | Earlier 104/104; final package rerun pending. | -| [web3.trading #18](https://github.com/pathscale/web3.trading/pull/18) | Latest run 101/103: dark-theme GET STARTED contrast measured 1.96:1, and the final carousel slide did not remain stable within its outcome window. Both need investigation. | +| [web3.trading #18](https://github.com/pathscale/web3.trading/pull/18) | Latest run 102/103. Theme contrast passes after the driver waits for the rendered outcome; native capture confirms readable settled paint. The last carousel slide's 500ms stability check still fails and needs investigation. | | [ui-starter-app #13](https://github.com/pathscale/ui-starter-app/pull/13) | Earlier 144/144; final package rerun pending. | | [agencyzero #211](https://github.com/pathscale/agencyzero/pull/211), [#212](https://github.com/pathscale/agencyzero/pull/212) | UI and control integration in scope; core-specific features are handed to a dedicated owner after UI is ready. | @@ -109,12 +113,11 @@ session recovery, and relevant security settings need outcomes, not just screen presence. TOTP/recovery verification remains incomplete. Only uniquely named disposable QA applications may be changed or deleted by the lifecycle checks. -Honey's creation handler now awaits its mutation, so the form's submitting state -covers the backend request. This is not yet evidence that the cold-submit failure -is fixed. Disposable-bundle tracing confirms validation completes, the mutation -starts, and execution waits at CreateApp's RPC. One diagnostic lifecycle passed -15/15, but three consecutive repetitions stalled at that RPC. Request/response -tracing is now the next diagnostic step; no stable fix has been established. +Honey's creation handler now awaits its mutation, so submitting covers the backend +request. Tracing showed validation completed but CreateApp was never sent when +GetApps and CreateApp queued during WebSocket connection. The first open listener +removed itself and chuzz skipped the next listener. The host now snapshots +listeners before dispatch; three clean lifecycles and the full suite pass. Pathscale restoration, if approved, should mirror crates.vip's low-cost deployment: shared IPv4, shared CPU, one small machine. The crates backend's `fly.toml` and @@ -163,8 +166,9 @@ The font-enabled host, driver, UI package, Honey and Worktables build successful UI's full native sweep after the Calendar fix passes 273 checks across 75 fixtures; API/package checks pass across 187 components and 1,002 files. The native gesture/paint regression and ps-qa clippy/tests pass. Worktables passes -112/112. Honey still fails its cold first application submission. +112/112, JS Software 316/316, and Honey 192/192. Honey's additional recovery-code +flow passes hands-on; its reusable runner still needs its own final run. -The current shared build/test window is open. Continue Honey request/response -tracing, consumer verification, and CI integration, coordinating the next quiet -measurement window with the core task. No deployment sign-off yet. +The core task is preparing another quiet measurement window. Use that period +for source and PR review; resume recovery-runner and final consumer verification +when the shared window reopens. No deployment sign-off yet. From bf685d8f883dd14dcf627c6674682cddd882a854 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 02:00:13 +0700 Subject: [PATCH 23/24] docs(release): record final packed-candidate evidence --- docs/release-readiness-2026-09-12.md | 106 +++++++++++++++++---------- 1 file changed, 66 insertions(+), 40 deletions(-) diff --git a/docs/release-readiness-2026-09-12.md b/docs/release-readiness-2026-09-12.md index e12bc2dc..c0bf9a5a 100644 --- a/docs/release-readiness-2026-09-12.md +++ b/docs/release-readiness-2026-09-12.md @@ -1,6 +1,7 @@ # UI release review — 12 September 2026 -Status: **not ready to deploy**. This is the working release gate, not a sign-off. +Status: **UI package ready for owner review; not ready to deploy**. This is the +working release gate, not a sign-off. The owner reviews the existing PRs before deployment. Do not land a branch that automatically deploys or publishes without approval. Before creating a Fly dev instance, contact the owner so they can be online for questions. @@ -13,20 +14,19 @@ version; CI remains responsible for assigning and publishing it after approval. - [x] Build the candidate ps-qa and font-enabled host; verify post-action paint, explicit gridcell targets, keyboard shortcuts, and transformed pointer actions. -- [x] Verify ps-blitz CI with the pinned coordinated stack. Run 34631808347 - passes after excluding nested dependency workspaces. A subsequent pin refresh - includes the newly verified WebSocket and contrast-settling fixes. -- [ ] Build and pack fresh UI source with solid-layouts 0.2.4; run the full native - component sweep, API/package gates, and clean consumer builds. -- [x] Resolve Honey's cold first-submit failure and run the 192-check suite, +- [x] Verify ps-blitz CI with the pinned coordinated stack. Run 34632611122 + passes at 97ca22ca, including the WebSocket listener and contrast-settling fixes. +- [x] Build and pack fresh UI source with solid-layouts 0.2.4; run the full native + component sweep, API/package gates, and consumer builds against the packed candidate. +- [x] Resolve Honey's cold first-submit failure and run the expanded 196-check suite, including Platform Admin, App Admin, Guest, and password change/restore. -- [ ] Finish reproducible recovery-code coverage; TOTP confirmation and Telegram - enrollment/login remain separate security gates. +- [x] Finish reproducible recovery-code coverage. TOTP confirmation and Telegram + enrollment/login remain separate security gates for Honey. - [x] Verify Worktables editing, cancellation, undo, findings, and zoom (112/112). - [x] Confirm the packed Calendar fix in js.software (316/316). -- [ ] Resolve Web3 carousel settling. Its theme group now passes 18/18 after - ps-qa learned to wait for contrast within the declared outcome window. -- [ ] Repeat scoped site E2E against the final package, recording missing backend +- [x] Resolve Web3 carousel settling. Its complete final run passes 103/103 after + the landing surface was scoped away from the independently animated chat halo. +- [x] Repeat scoped site E2E against the final package, recording missing backend contracts separately from library regressions. - [ ] Push verified changes to the existing PRs and reconcile their descriptions and CI results with this evidence for owner review. @@ -64,15 +64,15 @@ Worktables' clean install still awaits the separate house DSL SDK 0.1.2 release. | --- | --- | --- | | Native option labels and selected state disappeared in the control refactor | Six regression tests restored; native select fixture passes in Linux CI and with the final local stack | Verify published protocol integration | | Transformed client rectangles disagreed with painted controls | Four geometry tests, inline fragment tests, and full Linux suite pass; final pointer fixture and Worktables zoom/drag checks pass | CI host boundary and published integration | -| CI needs the coordinated unpublished runtime stack | Exact dependency revisions pinned; nested workspace exclusions resolved Cargo inheritance. Coordinated CI run 34631808347 passes | Verify refreshed socket/contrast pins and registry integration | +| CI needs the coordinated unpublished runtime stack | Exact dependency revisions pinned; nested workspace exclusions resolved Cargo inheritance. Coordinated CI run 34632611122 passes with refreshed socket/contrast revisions | Verify registry integration after approval | | Calendar cells captured selection state once | Cell state is now reactive; six native checks verify selection changes and controlled callbacks in both directions. Packed js.software run passes 316/316 | Owner review | -| Honey CreateApp stalled during socket connection | chuzz iterated a live listener array; the first RPC removed its open listener and skipped the next. Snapshot dispatch fixes three consecutive 15-step lifecycles and the full 192-check suite | Refreshed host CI | +| Honey CreateApp stalled during socket connection | chuzz iterated a live listener array; the first RPC removed its open listener and skipped the next. Snapshot dispatch fixes three consecutive 15-step lifecycles and the full 192-check suite; refreshed coordinated CI passes | GUI build review and registry integration | | Responsive layout classes were purged from consumers | Purge manifest includes responsive Grid/Flex classes; 24x landing checks passed | Full consumer rebuilds from the final package | | Form submission discarded schema output | Typed schema output preserved; six native form checks and Honey's expanded suite pass | Owner review | | ConnectionSettings missing from layout manifest | Export added; isolated package consumer build passed | Pays runtime checks | | CI weakened paint checks on a fontless host | Font-enabled host builds; UI CI selects full checks; fresh local sweep passes 273 checks across 75 component fixtures | Repeat website theme checks; verify Linux CI after dependency release | | ps-qa measured paint before input, then sampled contrast transitions too early | Reads moved after input; contrast honors the outcome and stability windows. Six native driver scenarios pass, including delayed repair and persistent contrast failure. Web3 theme group passes 18/18 | Final stack sweeps | -| ps-qa rejected explicit gridcell targets | Explicit role selectors now bypass the generic inventory role list while retaining actionability checks | Native role regression and js.software calendar rerun | +| ps-qa rejected explicit gridcell targets | Explicit role selectors now bypass the generic inventory role list while retaining actionability checks; native Calendar and packed js.software checks pass | Owner review | | ps-qa's older drag diagnostic only scrolls containers | Actual pointer dragging/cancellation added to CLI and declarative checks; native fixture and Worktables movement/cancellation/undo checks pass | PR and CI review | | UI sweep could accept bundles older than the library source | Staleness guard now includes library source and package output; confirmed it rejects the current outdated bundle before launching a host | Fresh full build and sweep | @@ -92,27 +92,45 @@ final package and runtime. Check definitions have changed since some runs. | Repository / existing PR | Observed result or blocker | | --- | --- | -| [honey.id #332](https://github.com/pathscale/honey.id/pull/332) | Expanded suite passes 192/192; three standalone app lifecycles pass consecutively. Recovery-code generation, two login/rotation cycles, old-code rejection, save gates, and unchanged-password login passed hands-on. Repeatable recovery runner is being finalized; TOTP and Telegram remain unverified. | -| [worktables.dev #9](https://github.com/pathscale/worktables.dev/pull/9) | UI/SVG editor replaces Cytoscape/ELK. Fresh package run passes 112/112, including 33 designer and 7 findings checks. Real pointer movement, cancellation, undo, zoom, and emitted schema edits pass. Clean install still awaits house DSL SDK 0.1.2; final visual inspection is pending. | -| [crates.vip #1](https://github.com/pathscale/crates.vip/pull/1) | Earlier 71/71; authentication is deliberately bypassed in both client and backend, so this is not evidence of authenticated role coverage. | -| [24x.ai #11](https://github.com/pathscale/24x.ai/pull/11) | Earlier 137/141; corrected responsive landing 16/16. Auth app identity/backend setup still needs final verification. | +| [honey.id #332](https://github.com/pathscale/honey.id/pull/332) | Expanded suite passes 193/196. The three failures truthfully identify the backend's empty API-key regeneration response; the dedicated error flow passes 18/18. Recovery-code generation, two login/rotation cycles, old-code rejection, save gates, unchanged-password login, and sign-out pass in a reusable 33/33 runner. TOTP and Telegram remain unverified. | +| [worktables.dev #9](https://github.com/pathscale/worktables.dev/pull/9) | UI/SVG editor replaces Cytoscape/ELK. Final packed-package run passes 112/112, including 33 designer and 7 findings checks. Real pointer movement, cancellation, undo, zoom, and emitted schema edits pass. Visual inspection confirms the UI cards, native SVG relationships, toolbar, and inspector. Clean install still awaits house DSL SDK 0.1.2. | +| [crates.vip #1](https://github.com/pathscale/crates.vip/pull/1) | Final packed-package run passes 71/71; authentication is deliberately bypassed in both client and backend, so this is not evidence of authenticated role coverage. | +| [24x.ai #11](https://github.com/pathscale/24x.ai/pull/11) | Final packed-package runs pass 141/141 at desktop width and 20/20 at phone width. The dev login still authenticates under Honey's dev application because 24x has no usable dev registration of its own. | | [js.software #53](https://github.com/pathscale/js.software/pull/53) | Packed Calendar fix passes 316/316. CI includes all declared groups. | -| [kard.vip #8](https://github.com/pathscale/kard.vip/pull/8) | Earlier 223/223; demo actions do not prove payment functionality. | -| [nofilter.io #340](https://github.com/pathscale/nofilter.io/pull/340) | Earlier 127/129; Guest login route failures and suspended dev backend. | -| [pathscale.com #17](https://github.com/pathscale/pathscale.com/pull/17) | Public site remains in scope. Owner confirmed the old portal has little value and need not block UI. Configured `pathscale-be` no longer exists; do not recreate without a reviewed deployment plan. | -| [pays.online #166](https://github.com/pathscale/pays.online/pull/166) | Package build passes after ConnectionSettings export fix. Wallet settings call methods absent from the backend schema; onboarding contains unfinished handlers. The configured Honey app id is a UUID rather than the required 16-character public id. Real payment actions require a defined dev setup and review. | -| [promptsyntax.org #18](https://github.com/pathscale/promptsyntax.org/pull/18) | Earlier 129/129; final package rerun pending. | -| [support.cafe #12](https://github.com/pathscale/support.cafe/pull/12) | Earlier 104/104; final package rerun pending. | -| [web3.trading #18](https://github.com/pathscale/web3.trading/pull/18) | Latest run 102/103. Theme contrast passes after the driver waits for the rendered outcome; native capture confirms readable settled paint. The last carousel slide's 500ms stability check still fails and needs investigation. | -| [ui-starter-app #13](https://github.com/pathscale/ui-starter-app/pull/13) | Earlier 144/144; final package rerun pending. | +| [kard.vip #8](https://github.com/pathscale/kard.vip/pull/8) | Final packed-package run passes 223/223; demo actions do not prove payment functionality. | +| [nofilter.io #340](https://github.com/pathscale/nofilter.io/pull/340) | Final packed-package run passes 129/129. | +| [pathscale.com #17](https://github.com/pathscale/pathscale.com/pull/17) | The public/UI flow and carousel pass against the final package. The dev run passes 105/124: the known username and wrong-password refusal work, but the correct-password callback does not create a protected session, stranding 19 dependent portal/settings checks. Owner confirmed the old portal has little value and need not block UI. Configured `pathscale-be` no longer exists; do not recreate without a reviewed deployment plan. | +| [pays.online #166](https://github.com/pathscale/pays.online/pull/166) | Packed-package typecheck and build pass after adopting shared ConnectionSettings. There is no ps-qa profile. Wallet settings call methods absent from the backend schema; onboarding contains unfinished handlers. The configured Honey app id is a UUID rather than the required 16-character public id. Real payment actions require a defined dev setup and review. | +| [promptsyntax.org #18](https://github.com/pathscale/promptsyntax.org/pull/18) | Final packed-package run passes 129/129. | +| [support.cafe #12](https://github.com/pathscale/support.cafe/pull/12) | Final packed-package run passes 104/104. | +| [web3.trading #18](https://github.com/pathscale/web3.trading/pull/18) | Final packed-package run passes 103/103. Theme contrast settles correctly, the last carousel slide remains stable for 500ms, and the guest chat closes. | +| [ui-starter-app #13](https://github.com/pathscale/ui-starter-app/pull/13) | Final packed-package run passes 144/144. | | [agencyzero #211](https://github.com/pathscale/agencyzero/pull/211), [#212](https://github.com/pathscale/agencyzero/pull/212) | UI and control integration in scope; core-specific features are handed to a dedicated owner after UI is ready. | Honey verification must cover **Platform Admin, App Admin, and Guest** with real allowed and denied behavior. Application creation, saved edits, deletion, logout, session recovery, and relevant security settings need outcomes, not just screen -presence. TOTP/recovery verification remains incomplete. Only uniquely named +presence. Recovery verification is complete; TOTP and Telegram remain incomplete. Only uniquely named disposable QA applications may be changed or deleted by the lifecycle checks. +Honey's backend production approval has a separate security review item. The +July 27 audit's app-token trust concern still matches the inspected auth backend +at `60d37dc`: `src/services/auth/app_token.rs` checks that a caller-selected +source app exists and accepts its callback's user identity, without an explicit +source-to-target trust check. This is a source finding, not a live exploit test. +Do not treat passing recovery or UI checks as closing that backend boundary. + +Do not count the current Honey or js.software `biome` scripts as validation: +their manifests install the unrelated `biome` 0.3.3 package rather than +`@biomejs/biome`. Its CLI can return success without checking files. Their +TypeScript, build, and native E2E results above are separate evidence. Repairing +the formatter dependency and stale configuration remains tooling cleanup. + +UI CI run 34633005400 still stops at chuzz master's GUI `layouts:local` build +step (exit 127). PR #45 gates that build behind the GUI feature; its local +font-enabled headless build and coordinated engine CI pass. Keep the release +order rather than weakening UI checks to bypass the unreleased host. + Honey's creation handler now awaits its mutation, so submitting covers the backend request. Tracing showed validation completed but CreateApp was never sent when GetApps and CreateApp queued during WebSocket connection. The first open listener @@ -160,15 +178,23 @@ version to 0.3.7. Neither needs replaying onto 0.4.8. The original `solid-layouts` checkout contains other local work; the published root style fix was made in `/Users/revenge/code/solid-layouts-ui-release` to preserve it. -## Pending final run - -The font-enabled host, driver, UI package, Honey and Worktables build successfully. -UI's full native sweep after the Calendar fix passes 273 checks -across 75 fixtures; API/package checks pass across 187 components and 1,002 files. -The native gesture/paint regression and ps-qa clippy/tests pass. Worktables passes -112/112, JS Software 316/316, and Honey 192/192. Honey's additional recovery-code -flow passes hands-on; its reusable runner still needs its own final run. - -The core task is preparing another quiet measurement window. Use that period -for source and PR review; resume recovery-runner and final consumer verification -when the shared window reopens. No deployment sign-off yet. +## Final local release candidate + +The font-enabled host, ps-qa 0.7.1 driver, packed UI package, Honey, and Worktables +build successfully. UI's final native sweep passes 273 checks across 75 fixtures; +API/package checks pass across 187 components and 1,002 files. The full chuzz GUI +release build, workspace tests, and clippy pass. Worktables passes 112/112, JS +Software 316/316, Web3 103/103, 24x 141/141 plus 20/20 mobile, crates 71/71, +kard 223/223, nofilter 129/129, Prompt Syntax 129/129, support.cafe 104/104, +and the starter 144/144. + +Honey's complete run is 193/196 because the backend returns no regenerated API +key; the truthful error path passes 18/18 and the reusable recovery runner passes +33/33. Pathscale's public/UI flow passes, while its dev application callback does +not establish the protected session. Pays builds but has no native QA profile and +has explicit backend contract gaps. These site-specific boundaries do not indicate +a UI package regression. + +No deployment sign-off is implied. The owner must review the PRs, then the five +dependency releases must publish in order before registry CI and approved website +deployments can complete. From dcfe3e473b13f84f3b7f25fa3d7a4b8a6a1a38c8 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 03:02:20 +0700 Subject: [PATCH 24/24] docs(release): record public-stack approval --- docs/release-readiness-2026-09-12.md | 51 ++++++++++++++-------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/docs/release-readiness-2026-09-12.md b/docs/release-readiness-2026-09-12.md index c0bf9a5a..8beb0e32 100644 --- a/docs/release-readiness-2026-09-12.md +++ b/docs/release-readiness-2026-09-12.md @@ -1,16 +1,14 @@ # UI release review — 12 September 2026 -Status: **UI package ready for owner review; not ready to deploy**. This is the -working release gate, not a sign-off. -The owner reviews the existing PRs before deployment. Do not land a branch that -automatically deploys or publishes without approval. Before creating a Fly dev -instance, contact the owner so they can be online for questions. +Status: **UI 3.2.0 package release approved and locally verified**. Website +deployments remain separately reviewed. Before creating a Fly dev instance, +contact the owner so they can be online for questions. ## Concrete TODO -The release script currently computes **3.2.0** from npm's 3.1.0 baseline and -the branch's conventional commits. This is a proposed release, not a published -version; CI remains responsible for assigning and publishing it after approval. +The release script computes **3.2.0** from npm's 3.1.0 baseline and the branch's +conventional commits. Local release verification is complete; the repository's +release workflow assigns and publishes the version after the fast-forward. - [x] Build the candidate ps-qa and font-enabled host; verify post-action paint, explicit gridcell targets, keyboard shortcuts, and transformed pointer actions. @@ -28,23 +26,25 @@ version; CI remains responsible for assigning and publishing it after approval. the landing surface was scoped away from the independently animated chat halo. - [x] Repeat scoped site E2E against the final package, recording missing backend contracts separately from library regressions. -- [ ] Push verified changes to the existing PRs and reconcile their descriptions - and CI results with this evidence for owner review. -- [ ] After owner approval, release the dependency chain in order, verify registry - availability, and deploy only the approved website changes. +- [x] Push the verified library, driver, runtime, and host changes to their + existing release branches and reconcile their descriptions with local evidence. +- [x] Release ps-blitz 0.4.8, blitz-control-protocol 0.5.0, ps-qa 0.7.1, + tauri-runtime-blitz 0.4.0, and the Chuzz 0.1.37 host in dependency order. +- [ ] Release UI 3.2.0 and verify a fresh consumer install from npm. +- [ ] Deploy only the separately reviewed website changes. ## Release sequence -1. [ps-blitz #95](https://github.com/pathscale/ps-blitz/pull/95): publish 0.4.8 - after the select accessibility and transformed geometry changes are verified. +1. [ps-blitz #95](https://github.com/pathscale/ps-blitz/pull/95): 0.4.8 published. 2. [ps-observability #21](https://github.com/pathscale/ps-observability/pull/21): - publish blitz-control-protocol 0.5.0 and ps-qa 0.7.1 against that engine. + blitz-control-protocol 0.5.0 and ps-qa 0.7.1 published against that engine. 3. [tauri-runtime-blitz #57](https://github.com/pathscale/tauri-runtime-blitz/pull/57): - publish 0.4.0 against the shared protocol. -4. [chuzz #45](https://github.com/pathscale/chuzz/pull/45): shared document actions, - headless build gating, and a font-enabled website QA host. -5. [UI #289](https://github.com/pathscale/UI/pull/289): verify the packaged library - and its consumers with the released host and driver, then publish through CI. + 0.4.0 published against the shared protocol. +4. [chuzz #45](https://github.com/pathscale/chuzz/pull/45) and + [#46](https://github.com/pathscale/chuzz/pull/46): shared document actions, + headless build gating, and the signed 0.1.37 host published. +5. [UI #289](https://github.com/pathscale/UI/pull/289): publish 3.2.0 after the + packaged library and its consumers passed against the released host and driver. 6. Review and deploy approved website PRs using the published library. The older handover put tauri-runtime-blitz before ps-observability. Its manifest @@ -62,7 +62,7 @@ Worktables' clean install still awaits the separate house DSL SDK 0.1.2 release. | Finding | Current evidence | Remaining verification | | --- | --- | --- | -| Native option labels and selected state disappeared in the control refactor | Six regression tests restored; native select fixture passes in Linux CI and with the final local stack | Verify published protocol integration | +| Native option labels and selected state disappeared in the control refactor | Six regression tests restored; the native Select fixture passes all 7 outcomes with published ps-blitz 0.4.8, protocol 0.5.0, ps-qa 0.7.1, and Chuzz 0.1.37 | Complete | | Transformed client rectangles disagreed with painted controls | Four geometry tests, inline fragment tests, and full Linux suite pass; final pointer fixture and Worktables zoom/drag checks pass | CI host boundary and published integration | | CI needs the coordinated unpublished runtime stack | Exact dependency revisions pinned; nested workspace exclusions resolved Cargo inheritance. Coordinated CI run 34632611122 passes with refreshed socket/contrast revisions | Verify registry integration after approval | | Calendar cells captured selection state once | Cell state is now reactive; six native checks verify selection changes and controlled callbacks in both directions. Packed js.software run passes 316/316 | Owner review | @@ -70,7 +70,7 @@ Worktables' clean install still awaits the separate house DSL SDK 0.1.2 release. | Responsive layout classes were purged from consumers | Purge manifest includes responsive Grid/Flex classes; 24x landing checks passed | Full consumer rebuilds from the final package | | Form submission discarded schema output | Typed schema output preserved; six native form checks and Honey's expanded suite pass | Owner review | | ConnectionSettings missing from layout manifest | Export added; isolated package consumer build passed | Pays runtime checks | -| CI weakened paint checks on a fontless host | Font-enabled host builds; UI CI selects full checks; fresh local sweep passes 273 checks across 75 component fixtures | Repeat website theme checks; verify Linux CI after dependency release | +| A fontless host weakens paint checks | The font-enabled release host builds; a fresh local sweep against the public dependency stack passes 273 checks across 75 component fixtures | Complete | | ps-qa measured paint before input, then sampled contrast transitions too early | Reads moved after input; contrast honors the outcome and stability windows. Six native driver scenarios pass, including delayed repair and persistent contrast failure. Web3 theme group passes 18/18 | Final stack sweeps | | ps-qa rejected explicit gridcell targets | Explicit role selectors now bypass the generic inventory role list while retaining actionability checks; native Calendar and packed js.software checks pass | Owner review | | ps-qa's older drag diagnostic only scrolls containers | Actual pointer dragging/cancellation added to CLI and declarative checks; native fixture and Worktables movement/cancellation/undo checks pass | PR and CI review | @@ -126,10 +126,9 @@ their manifests install the unrelated `biome` 0.3.3 package rather than TypeScript, build, and native E2E results above are separate evidence. Repairing the formatter dependency and stale configuration remains tooling cleanup. -UI CI run 34633005400 still stops at chuzz master's GUI `layouts:local` build -step (exit 127). PR #45 gates that build behind the GUI feature; its local -font-enabled headless build and coordinated engine CI pass. Keep the release -order rather than weakening UI checks to bypass the unreleased host. +The previous UI run stopped at Chuzz's stale GUI build path. Chuzz #45 and #46 +fixed that release path; the published 0.1.37 host then passed the complete local +273-check component sweep. The checks were kept at the full font-enabled profile. Honey's creation handler now awaits its mutation, so submitting covers the backend request. Tracing showed validation completed but CreateApp was never sent when