diff --git a/.agents/skills/codeatlas-ui/API.md b/.agents/skills/codeatlas-ui/API.md index 1a49f811..28608b46 100644 --- a/.agents/skills/codeatlas-ui/API.md +++ b/.agents/skills/codeatlas-ui/API.md @@ -584,6 +584,19 @@ Tokens: `--ca-carousel-per-view` | `name?` | `string` | | | | `onblur?` | `(e: FocusEvent) => void` | | | +## ChartScope + +`import { ChartScope } from "@codeatlasdev/ui/charts"` + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `scope?` | `HTMLElement \| null` | `null` | The element to read tokens from. Omit to use the wrapper div this component renders. | +| `class?` | `string` | `''` | | + +Snippets: `children` + +Parts: `ca-chart-scope` + ## Checkbox `import { Checkbox } from "@codeatlasdev/ui"` @@ -713,6 +726,29 @@ Variables: `--ca-collapsible-height` Data attributes: `data-state` (`closed` `open`) +## ColorField + +`import { ColorField } from "@codeatlasdev/ui"` + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `label` | `string` | | | +| `value?` | `string` | `'#000000'` | `bind:` Committed color as a CSS string. Invalid input never reaches it. | +| `format?` | `'hex' \| 'oklch'` | `'hex'` | What the native picker writes: `hex` (default) or `oklch`. | +| `against?` | `string` | | Background to measure contrast against (any CSS color). Omit to hide the readout. | +| `role?` | `'text' \| 'fill'` | `'text'` | Role of `value` in the contrast pair: `text` (value on `against`) or `fill` (against on value). | +| `minRatio?` | `number` | `4.5` | Minimum ratio that counts as passing. 4.5 for text, 3 for UI parts. | +| `description?` | `string` | | | +| `disabled?` | `boolean` | `false` | | +| `class?` | `string` | `''` | | +| `onchange?` | `(value: string) => void` | | | + +Parts: `ca-color-field` `ca-color-field__contrast` `ca-color-field__level` `ca-color-field__native` `ca-color-field__pick` `ca-color-field__ratio` `ca-color-field__swatch` + +Variables: `--ca-color-field-fill` + +Data attributes: `data-disabled`, `data-pass` + ## Combobox `import { Combobox } from "@codeatlasdev/ui"` @@ -3048,6 +3084,7 @@ Slider — single or range value input. | `step?` | `number` | `1` | | | `disabled?` | `boolean` | `false` | | | `class?` | `string` | `''` | | +| `label?` | `string` | | Accessible name of the thumb(s). Without it a thumb is announced as "Value 40". A range appends "minimum"/"maximum". | | `onchange?` | `(value: number[]) => void` | | Called on change. Always receives array regardless of single/range mode. | | `sound?` | `SoundProp` | | Plays `tick` for each step the user crosses (a cue name replaces it); off under reduced motion. Needs `@codeatlasdev/ui/sound`. | @@ -3055,6 +3092,27 @@ Parts: `ca-slider` `ca-slider__range` `ca-slider__thumb` `ca-slider__track` Data attributes: `data-disabled` +## SliderField + +`import { SliderField } from "@codeatlasdev/ui"` + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `label` | `string` | | Visible label and the accessible name of both controls. | +| `value?` | `number` | `0` | `bind:` | +| `min?` | `number` | `0` | | +| `max?` | `number` | `100` | | +| `step?` | `number` | `1` | | +| `unit?` | `string` | | Unit shown after the number (`px`, `rem`, `deg`, `x`). | +| `description?` | `string` | | Content under the control. | +| `disabled?` | `boolean` | `false` | | +| `class?` | `string` | `''` | | +| `onchange?` | `(value: number) => void` | | | + +Parts: `ca-slider-field` `ca-slider-field__head` `ca-slider-field__hint` `ca-slider-field__label` `ca-slider-field__value` + +Data attributes: `data-disabled` + ## SoundToggle `import { SoundToggle } from "@codeatlasdev/ui/sound"` @@ -3759,6 +3817,14 @@ Move `id` per a drop event inside plain arrays: the reorder/move helper apps usu function applyMove(lists: Record, e: DndDropEvent, key: (item: T) => string): Record ``` +### BUILTIN_PRESETS + +`import { BUILTIN_PRESETS } from "@codeatlasdev/ui"` + +```ts +const BUILTIN_PRESETS: BuiltinPresetSource[] +``` + ### collectPaths `import { collectPaths } from "@codeatlasdev/ui"` @@ -3792,6 +3858,16 @@ const colorScheme: { } ``` +### contrast + +`import { contrast } from "@codeatlasdev/ui"` + +WCAG contrast ratio of `fg` on `bg`. A translucent `fg` is composited over `bg`, and a translucent `bg` over white, like a browser paints them. Null when either cannot be evaluated (`var()`, other keywords). + +```ts +function contrast(fg: string, bg: string): number | null +``` + ### copyToClipboard `import { copyToClipboard } from "@codeatlasdev/ui"` @@ -3885,6 +3961,16 @@ Design-style recipes by id (default, liquid, swiss, ... editorial, terminal): ea const DESIGN_STYLES: Record ``` +### designDefaults + +`import { designDefaults } from "@codeatlasdev/ui"` + +The stylesheet default (`tokens.css`) of every knob `designTokens` can write, so a scoped preview can reset what an outer theme set before it applies its own edits: `designTokens` leaves a value out when it equals the default, which lets the page's theme cascade into an island. Values here are exactly the declarations of `tokens.css`; `tests/theme-defaults.test.ts` keeps them in step. + +```ts +function designDefaults(): Record +``` + ### designStyle `import { designStyle } from "@codeatlasdev/ui"` @@ -3969,6 +4055,16 @@ const elevation: { } ``` +### extractPalette + +`import { extractPalette } from "@codeatlasdev/ui"` + +Dominant colors of an RGBA pixel buffer (`ImageData.data`), by k-means in OKLab so clusters are perceptual rather than channel-wise. Transparent pixels are ignored; the buffer is subsampled to at most `maxSamples` pixels. Deterministic (farthest-point seeding, no randomness). Result is sorted by `share`, largest first, and merged so no two entries sit within `minDistance` in OKLab. + +```ts +function extractPalette(rgba: ArrayLike, options?: { k?: number | undefined; maxSamples?: number | undefined; minDistance?: number | undefined; }): PaletteColor[] +``` + ### findActiveItem `import { findActiveItem } from "@codeatlasdev/ui"` @@ -4087,6 +4183,16 @@ Format a number with locale-aware thousands separator. function formatNumber(value: number, locale?: string, options?: Intl.NumberFormatOptions): string ``` +### formatOklch + +`import { formatOklch } from "@codeatlasdev/ui"` + +`oklch(L C H)` string with 3-decimal lightness/chroma and 1-decimal hue. + +```ts +function formatOklch(options: Oklch): string +``` + ### formatRelativeTime `import { formatRelativeTime } from "@codeatlasdev/ui"` @@ -4317,6 +4423,16 @@ Call `fn` after the scheme, preset or surface style of the page changes (the `cl function onTokensChange(fn: () => void): () => void ``` +### parseColorInput + +`import { parseColorInput } from "@codeatlasdev/ui"` + +Parse what a user types into a color field: `#rgb`, `#rrggbb`, `rgb()`, `oklch()`, `color-mix()`, `white`/`black`, or a bare `rrggbb`. Returns the canonical `#rrggbb` and OKLCH, or null. + +```ts +function parseColorInput(input: string): { hex: string; oklch: Oklch; alpha: number; css: string } | null +``` + ### passwordScore `import { passwordScore } from "@codeatlasdev/ui"` @@ -4327,6 +4443,36 @@ Pure, dependency-free strength estimate: unique characters and character classes function passwordScore(value: string): PasswordScore ``` +### pickBrand + +`import { pickBrand } from "@codeatlasdev/ui"` + +Pick the brand color of a palette: the most vivid cluster that is neither near-white nor near-black, weighted by how much of the image it covers. Falls back to the largest cluster for a grayscale image. + +```ts +function pickBrand(palette: PaletteColor[]): PaletteColor | null +``` + +### presetContrastIssues + +`import { presetContrastIssues } from "@codeatlasdev/ui"` + +Hand-set roles of a brand preset that miss WCAG AA, as findings: - `brandContrast` on `brandFill` under 4.5:1 (a role left unset is measured from the generated fill/contrast of that scheme, so a lone `brandContrast` is checked against the fill it will sit on); - `text`, `textSecondary` or `textHint` under 4.5:1 on any of the four surfaces **after compositing their alpha** over that surface (a translucent role is only as readable as what is behind it). Surfaces a preset hand-sets are used as written; the rest are the generated ones. Colors the build cannot evaluate (`var()`, other keywords) are skipped, never guessed. + +```ts +function presetContrastIssues(t: Pick): PresetContrastIssue[] +``` + +### presetTokens + +`import { presetTokens } from "@codeatlasdev/ui"` + +Light/dark token maps (with `color-scheme`) for a built-in or brand preset. + +```ts +function presetTokens(src: BuiltinPresetSource | PresetDefinition): { light: Record; dark: Record; } +``` + ### radius `import { radius } from "@codeatlasdev/ui"` @@ -4577,6 +4723,16 @@ RFC 4180 CSV (CRLF line ends, trailing newline). Columns default to the first ro function toCsv(rows: readonly T[], columns?: readonly ExportColumn[]): string ``` +### toHex + +`import { toHex } from "@codeatlasdev/ui"` + +Opaque `#rrggbb` of any color the theme parser understands (alpha is dropped). Null if it cannot be evaluated. + +```ts +function toHex(color: string): string | null +``` + ### toJson `import { toJson } from "@codeatlasdev/ui"` @@ -4597,6 +4753,16 @@ A design token (`--ca-brand-main`), a domain token by name (`"status.new"` for ` function tokenRgba(token: CaTokenName | (string & {}), alpha?: number): Rgba ``` +### toOklch + +`import { toOklch } from "@codeatlasdev/ui"` + +OKLCH of any evaluable CSS color (alpha dropped). Null if it cannot be evaluated. + +```ts +function toOklch(color: string): Oklch | null +``` + ### uid `import { uid } from "@codeatlasdev/ui"` @@ -4665,6 +4831,16 @@ On-screen keyboard tracker: `viewport.keyboardInset` is the layout-viewport heig const viewport: { readonly keyboardInset: number; init: () => () => void; } ``` +### wcagLevel + +`import { wcagLevel } from "@codeatlasdev/ui"` + +WCAG level a ratio reaches for normal text: AAA 7, AA 4.5, AA-large 3 (large text and UI parts), else fail. + +```ts +function wcagLevel(ratio: number): WcagLevel +``` + ### zIndex `import { zIndex } from "@codeatlasdev/ui"` @@ -4695,6 +4871,7 @@ Types: | `BrandColors` | `interface BrandColors { main: string; light: string; dark: string; contrast: string; }` | | | `Breakpoint` | `type Breakpoint = "xs" \| "sm" \| "md" \| "lg" \| "xl";` | | | `BuiltinPresetId` | `type BuiltinPresetId = \| "amber" \| "ocean" \| "crimson" \| "cyan" \| "violet" \| "emerald" \| "rose" \| "slate";` | Presets shipped in global.css. | +| `BuiltinPresetSource` | `interface BuiltinPresetSource { id: "amber" \| "ocean" \| "crimson" \| "cyan" \| "violet" \| "emerald" \| "rose" \| "slate"; label: string; color: `#${string}`; hue: number; chroma: number; lightL: number; overrides: { light: TokenMap; dark: TokenMap }; }` | | | `ButtonSizeTokens` | `interface ButtonSizeTokens { height?: CssValue; paddingX?: CssValue; fontSize?: CssValue; weight?: CssValue; tracking?: CssValue; }` | Typography and box tokens of one Button size. | | `ButtonTokens` | `interface ButtonTokens extends ButtonSizeTokens { weight?: CssValue; tracking?: CssValue; leading?: CssValue; gap?: CssValue; ghostBorderWidth?: CssValue; disabledBg?: CssValue; touchHeight?: CssValue; sm?: ButtonSizeTokens; md?: ButtonSizeTokens; lg?: ButtonSizeTokens; }` | | | `CardTokens` | `interface CardTokens { bg?: CssValue; radius?: CssValue; border?: CssValue; shadow?: CssValue; }` | | @@ -4758,12 +4935,15 @@ Types: | `NavReorderProps` | `interface NavReorderProps { items: NavItem[]; onReorder: (e: NavReorderEvent) => void; label: string; isActive: (item: NavItem) => boolean; }` | Props of the opt-in `NavReorder` list (see `NavExtras.reorder`). | | `NavSection` | `interface NavSection { label?: string; collapsible?: boolean; defaultOpen?: boolean; items: NavItem[]; onReorder?: (e: NavReorderEvent) => void; }` | | | `NeutralRamp` | `interface NeutralRamp { surfaceLight: SurfaceColors; surfaceDark: SurfaceColors; textLight: TextColors; textDark: TextColors; outlineLight: OutlineColors; outlineDark: OutlineColors; }` | | +| `Oklch` | `interface Oklch { l: number; c: number; h: number; }` | | | `OutlineColors` | `interface OutlineColors { subtle: string; default: string; strong: string; }` | | +| `PaletteColor` | `interface PaletteColor { hex: string; oklch: Oklch; share: number; }` | | | `Paper` | `type Paper = "mesh" \| "plain" \| "grid" \| "dots" \| "lines";` | Page background pattern. mesh = brand glow gradients (drawn by the glass and frosted materials); the rest are flat paper. | | `PasswordScore` | `type PasswordScore = 0 \| 1 \| 2 \| 3;` | 0 = empty, 1 = weak, 2 = fair, 3 = strong. | | `Politeness` | `type Politeness = "polite" \| "assertive";` | | | `PolymorphicAttributes` | `type PolymorphicAttributes = Omit< SvelteHTMLElements[T], "class" \| "children" \| "as" \| keyof Own > & Own;` | The tag's own attributes and event handlers, minus what the primitive declares itself (`Own`, `class`, `children`, `as`). | | `PolymorphicProps` | `type PolymorphicProps = { as?: T; class?: string; children?: Snippet; } & PolymorphicAttributes;` | Props of a primitive that renders `as` an element: the tag's own attributes and event handlers (`as="form"` takes `novalidate`, `onsubmit` with `currentTarget: HTMLFormElement`), its token props, `class` and `children`. | +| `PresetContrastIssue` | `interface PresetContrastIssue { preset: string; scheme: "light" \| "dark"; ratio: number; message: string; role?: TextRole; }` | | | `PresetDefinition` | `interface PresetDefinition { id: string; color: `#${string}`; label?: string; colors?: { light?: SchemeColors; dark?: SchemeColors }; tokens?: PresetTokenGroups; sizes?: PresetSizes; }` | A preset generated from one sRGB hex color (e.g. the logo color). | | `PresetId` | `type PresetId = BuiltinPresetId \| (string & {});` | A built-in preset or a brand preset registered via `ThemeOptions.presets`. | | `PresetSizes` | `interface PresetSizes { inputFontSize?: string; }` | | @@ -4802,6 +4982,7 @@ Types: | `UseListStateReturn` | `interface UseListStateReturn { get search(): string; set search(v: string); readonly filters: Record; setFilter(key: string, value: string \| undefined): void; clearFilters(): void; readonly hasFilters: boolean; readonly filtered: T[]; readonly count: string; readonly rows: T[]; get page(): number; set page(v: number); readonly pageCount: number; readonly pageInfo: string; readonly selected: Set; readonly allPageSelected: boolean; toggleAll(): void; toggleRow(id: T[keyof T]): void; clearSelection(): void; }` | | | `ValidationSchema` | `type ValidationSchema = \| { parse?: (data: Record) => unknown; safeParse?: (data: Record) => { success: boolean; error?: { flatten?: () => { fieldErrors: Partial> } }; }; _run?: ( data: Record, config: unknown ) => { output: unknown; issues?: Array<{ path: (string \| number)[]; message: string }>; }; } \| ((data: Record) => Partial>);` | | | `VibrancyLevel` | `type VibrancyLevel = "label" \| "secondary" \| "tertiary" \| "fill" \| "separator";` | | +| `WcagLevel` | `type WcagLevel = "AAA" \| "AA" \| "AA-large" \| "fail";` | | | `ZIndexKey` | `type ZIndexKey = keyof typeof zIndex;` | | #### AvatarTokens @@ -4819,6 +5000,18 @@ Types: | `dark` | `string` | | | `contrast` | `string` | | +#### BuiltinPresetSource + +| Field | Type | Notes | +|---|---|---| +| `id` | `"amber" \| "ocean" \| "crimson" \| "cyan" \| "violet" \| "emerald" \| "rose" \| "slate"` | | +| `label` | `string` | | +| `color` | ``#${string}`` | sRGB swatch for pickers and canvas. | +| `hue` | `number` | | +| `chroma` | `number` | | +| `lightL` | `number` | | +| `overrides` | `{ light: TokenMap; dark: TokenMap }` | | + #### ButtonSizeTokens | Field | Type | Notes | @@ -5234,6 +5427,14 @@ Types: | `outlineLight` | `OutlineColors` | | | `outlineDark` | `OutlineColors` | | +#### Oklch + +| Field | Type | Notes | +|---|---|---| +| `l` | `number` | Lightness, 0 to 1. | +| `c` | `number` | Chroma, 0 to ~0.37 inside sRGB. | +| `h` | `number` | Hue angle in degrees, 0 to 360. | + #### OutlineColors | Field | Type | Notes | @@ -5242,6 +5443,24 @@ Types: | `default` | `string` | | | `strong` | `string` | | +#### PaletteColor + +| Field | Type | Notes | +|---|---|---| +| `hex` | `string` | | +| `oklch` | `Oklch` | | +| `share` | `number` | Share of the sampled pixels in this cluster, 0 to 1. | + +#### PresetContrastIssue + +| Field | Type | Notes | +|---|---|---| +| `preset` | `string` | | +| `scheme` | `"light" \| "dark"` | | +| `ratio` | `number` | | +| `message` | `string` | | +| `role?` | `TextRole` | The role that fell short, when it is a hand-set text role (`brandContrast` issues leave it out). | + #### PresetDefinition | Field | Type | Notes | @@ -7372,6 +7591,122 @@ Types: | `semanticLight` | `SemanticColors` | | | `semanticDark` | `SemanticColors` | | +## @codeatlasdev/ui/utils/color + +### contrast + +`import { contrast } from "@codeatlasdev/ui/utils/color"` + +WCAG contrast ratio of `fg` on `bg`. A translucent `fg` is composited over `bg`, and a translucent `bg` over white, like a browser paints them. Null when either cannot be evaluated (`var()`, other keywords). + +```ts +function contrast(fg: string, bg: string): number | null +``` + +### extractPalette + +`import { extractPalette } from "@codeatlasdev/ui/utils/color"` + +Dominant colors of an RGBA pixel buffer (`ImageData.data`), by k-means in OKLab so clusters are perceptual rather than channel-wise. Transparent pixels are ignored; the buffer is subsampled to at most `maxSamples` pixels. Deterministic (farthest-point seeding, no randomness). Result is sorted by `share`, largest first, and merged so no two entries sit within `minDistance` in OKLab. + +```ts +function extractPalette(rgba: ArrayLike, options?: { k?: number | undefined; maxSamples?: number | undefined; minDistance?: number | undefined; }): PaletteColor[] +``` + +### formatOklch + +`import { formatOklch } from "@codeatlasdev/ui/utils/color"` + +`oklch(L C H)` string with 3-decimal lightness/chroma and 1-decimal hue. + +```ts +function formatOklch(options: Oklch): string +``` + +### oklchHex + +`import { oklchHex } from "@codeatlasdev/ui/utils/color"` + +Opaque `#rrggbb` for an OKLCH triple (gamut-clipped). + +```ts +function oklchHex(options: Oklch): string +``` + +### parseColorInput + +`import { parseColorInput } from "@codeatlasdev/ui/utils/color"` + +Parse what a user types into a color field: `#rgb`, `#rrggbb`, `rgb()`, `oklch()`, `color-mix()`, `white`/`black`, or a bare `rrggbb`. Returns the canonical `#rrggbb` and OKLCH, or null. + +```ts +function parseColorInput(input: string): { hex: string; oklch: Oklch; alpha: number; css: string } | null +``` + +### pickBrand + +`import { pickBrand } from "@codeatlasdev/ui/utils/color"` + +Pick the brand color of a palette: the most vivid cluster that is neither near-white nor near-black, weighted by how much of the image it covers. Falls back to the largest cluster for a grayscale image. + +```ts +function pickBrand(palette: PaletteColor[]): PaletteColor | null +``` + +### toHex + +`import { toHex } from "@codeatlasdev/ui/utils/color"` + +Opaque `#rrggbb` of any color the theme parser understands (alpha is dropped). Null if it cannot be evaluated. + +```ts +function toHex(color: string): string | null +``` + +### toOklch + +`import { toOklch } from "@codeatlasdev/ui/utils/color"` + +OKLCH of any evaluable CSS color (alpha dropped). Null if it cannot be evaluated. + +```ts +function toOklch(color: string): Oklch | null +``` + +### wcagLevel + +`import { wcagLevel } from "@codeatlasdev/ui/utils/color"` + +WCAG level a ratio reaches for normal text: AAA 7, AA 4.5, AA-large 3 (large text and UI parts), else fail. + +```ts +function wcagLevel(ratio: number): WcagLevel +``` + +Types: + +| Type | Declaration | Notes | +|---|---|---| +| `Oklch` | `interface Oklch { l: number; c: number; h: number; }` | | +| `PaletteColor` | `interface PaletteColor { hex: string; oklch: Oklch; share: number; }` | | +| `WcagLevel` | `type WcagLevel = "AAA" \| "AA" \| "AA-large" \| "fail";` | | + +#### Oklch + +| Field | Type | Notes | +|---|---|---| +| `l` | `number` | Lightness, 0 to 1. | +| `c` | `number` | Chroma, 0 to ~0.37 inside sRGB. | +| `h` | `number` | Hue angle in degrees, 0 to 360. | + +#### PaletteColor + +| Field | Type | Notes | +|---|---|---| +| `hex` | `string` | | +| `oklch` | `Oklch` | | +| `share` | `number` | Share of the sampled pixels in this cluster, 0 to 1. | + ## @codeatlasdev/ui/utils/palette-generator ### contrastRatio diff --git a/.agents/skills/codeatlas-ui/DEMOS.md b/.agents/skills/codeatlas-ui/DEMOS.md index a928cf2b..f4271ee4 100644 --- a/.agents/skills/codeatlas-ui/DEMOS.md +++ b/.agents/skills/codeatlas-ui/DEMOS.md @@ -384,3 +384,6 @@ Every demo has a stable number. Fetch one with `get_demo` (MCP) or https://ui.co | 987 | A whole page | layout/preview-stage | PreviewStage | | 988 | Hear it | feedback/sound | Button, SegmentedControl, SoundToggle | | 989 | Components | feedback/sound | Button, Dialog, Slider, Switch, Tabs, TabsList, TabsTrigger, SoundToggle | +| 990 | Brand color | inputs/color-field | ColorField | +| 991 | Text color | inputs/color-field | ColorField | +| 992 | Label and number | inputs/slider | SliderField | diff --git a/.changeset/charts-follow-theme-scope.md b/.changeset/charts-follow-theme-scope.md new file mode 100644 index 00000000..8410a3b6 --- /dev/null +++ b/.changeset/charts-follow-theme-scope.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": patch +--- + +Charts follow a theme scope. They read their colors with `getComputedStyle` on ``, so a chart inside a `ThemeIsland` or any `data-preset` / `data-theme-scope` element painted the page's colors, and its series palette ignored the island's preset. `ThemeIsland` now provides its element as the scope, and the new `ChartScope` (from `@codeatlasdev/ui/charts`) does the same for your own scoped containers. Without a scope nothing changes. diff --git a/.changeset/theme-studio-color-field.md b/.changeset/theme-studio-color-field.md new file mode 100644 index 00000000..aa5af364 --- /dev/null +++ b/.changeset/theme-studio-color-field.md @@ -0,0 +1,5 @@ +--- +"@codeatlasdev/ui": minor +--- + +New `ColorField` (swatch, native picker, `#hex`/`rgb()`/`oklch()` entry and a live WCAG contrast readout) and `SliderField` (label, slider and an editable number with a unit). `Slider` takes a `label` so a thumb is announced as "Radius" instead of "Value 40". The color helpers behind them are public: `parseColorInput`, `contrast`, `wcagLevel`, `toHex`, `toOklch`, `formatOklch`, `extractPalette` and `pickBrand` (derive a brand from the pixels of a logo), plus `presetContrastIssues` and `presetTokens` for theme editors. diff --git a/docs/css-api.md b/docs/css-api.md index b178bcd2..c5b39d8d 100644 --- a/docs/css-api.md +++ b/docs/css-api.md @@ -108,6 +108,12 @@ State attributes on `.ca-chip-group__chip`: `data-state="checked|unchecked"`, `d `.ca-code-snippet` `.ca-code-snippet__copy` `.ca-code-snippet__lang` `.ca-code-snippet__pre` `.ca-code-snippet__pre--raw` `.ca-code-snippet__toolbar` +## ColorField + +`.ca-color-field` `.ca-color-field__contrast` `.ca-color-field__level` `.ca-color-field__native` `.ca-color-field__pick` `.ca-color-field__ratio` `.ca-color-field__swatch` + +Contrast state is exposed as `[data-pass]` and `[data-level]` on `.ca-color-field__contrast`. + ## Collapsible `.ca-collapsible` `.ca-collapsible__body` `.ca-collapsible__chevron` `.ca-collapsible__content` `.ca-collapsible__title` `.ca-collapsible__trigger` `.ca-slide-down` `.ca-slide-up` @@ -395,6 +401,10 @@ The `skeleton` prop of `Avatar`, `KpiCard`, `TextField`, `Table`, `Card` and `Gl `.ca-slider` `.ca-slider__range` `.ca-slider__thumb` `.ca-slider__track` +## SliderField + +`.ca-slider-field` `.ca-slider-field__head` `.ca-slider-field__hint` `.ca-slider-field__label` `.ca-slider-field__value` + ## SoundToggle `.ca-rail-btn` `.ca-sound-toggle` `.ca-sound-toggle__btn` `.ca-sound-toggle__volume` diff --git a/packages/ui/package.json b/packages/ui/package.json index 6fbfd49a..26bcec9b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -93,6 +93,10 @@ "types": "./dist/utils/palette-generator.d.ts", "default": "./dist/utils/palette-generator.js" }, + "./utils/color": { + "types": "./dist/utils/color.d.ts", + "default": "./dist/utils/color.js" + }, "./vite": { "types": "./dist/vite-plugin.d.ts", "default": "./dist/vite-plugin.js" diff --git a/packages/ui/scripts/build-manifest.ts b/packages/ui/scripts/build-manifest.ts index 9dc130ac..851461ef 100644 --- a/packages/ui/scripts/build-manifest.ts +++ b/packages/ui/scripts/build-manifest.ts @@ -56,6 +56,7 @@ const API_ENTRIES: Record = { [`${PKG}/seo`]: "seo/index.ts", [`${PKG}/tokens`]: "tokens/index.ts", [`${PKG}/utils/palette-generator`]: "utils/palette-generator.ts", + [`${PKG}/utils/color`]: "utils/color.ts", }; export type ExportKind = "function" | "store" | "const" | "class" | "type"; diff --git a/packages/ui/src/lib/actions/ScrollFocusableHarness.test.svelte b/packages/ui/src/lib/actions/ScrollFocusableHarness.test.svelte index c629ef2e..099a4095 100644 --- a/packages/ui/src/lib/actions/ScrollFocusableHarness.test.svelte +++ b/packages/ui/src/lib/actions/ScrollFocusableHarness.test.svelte @@ -1,14 +1,12 @@ +
+
diff --git a/packages/ui/src/lib/charts/ChartScopeProbe.test.svelte b/packages/ui/src/lib/charts/ChartScopeProbe.test.svelte new file mode 100644 index 00000000..53edd786 --- /dev/null +++ b/packages/ui/src/lib/charts/ChartScopeProbe.test.svelte @@ -0,0 +1,19 @@ + + + diff --git a/packages/ui/src/lib/charts/GaugeChart.svelte b/packages/ui/src/lib/charts/GaugeChart.svelte index eda28b3d..19131e0a 100644 --- a/packages/ui/src/lib/charts/GaugeChart.svelte +++ b/packages/ui/src/lib/charts/GaugeChart.svelte @@ -3,6 +3,7 @@ import type { EChartsOption } from "echarts"; import ChartCanvas from "./ChartCanvas.svelte"; import { withChartTheme } from "./theme.js"; + import { useChartScope } from "./tokens.js"; import { chartTokens, resolveZoneColor } from "./tokens.js"; import type { GaugeZone } from "./types.js"; @@ -37,9 +38,11 @@ height = "280px", class: className = "", }: Props = $props(); + // Tokens come from the theme island this chart sits in (the page when there is none). + const scope = useChartScope(); const option = $derived.by(() => { - const t = chartTokens(); + const t = chartTokens(scope.el); const fmt = formatter ?? ((v: number) => `${v}%`); const seriesBase = { @@ -83,7 +86,7 @@ detail: { ...detail, fontSize: 36, fontWeight: "bold", offsetCenter: [0, 0] }, }, ], - }); + }, scope.el); } return withChartTheme({ @@ -113,7 +116,7 @@ : {}), }, ], - }); + }, scope.el); }); diff --git a/packages/ui/src/lib/charts/HeatmapChart.svelte b/packages/ui/src/lib/charts/HeatmapChart.svelte index ca7ed4eb..3bcc9eb2 100644 --- a/packages/ui/src/lib/charts/HeatmapChart.svelte +++ b/packages/ui/src/lib/charts/HeatmapChart.svelte @@ -5,6 +5,7 @@ import type { EChartsOption } from "echarts"; import ChartCanvas from "./ChartCanvas.svelte"; import { withChartTheme } from "./theme.js"; + import { useChartScope } from "./tokens.js"; import { chartPalette, withAlpha } from "./palette.js"; import { chartTokens } from "./tokens.js"; import type { CalendarHeatmapPoint, MatrixHeatmapPoint } from "./types.js"; @@ -40,6 +41,8 @@ height = "240px", class: className = "", }: Props = $props(); + // Tokens come from the theme island this chart sits in (the page when there is none). + const scope = useChartScope(); function buildCalendarOption( t: ReturnType, @@ -82,7 +85,7 @@ data, }, ], - }); + }, scope.el); } function buildMatrixOption( @@ -145,12 +148,12 @@ animationDelay: (idx: number) => idx * 2, }, ], - }); + }, scope.el); } const option = $derived.by(() => { - const t = chartTokens(); - const palette = chartPalette(); + const t = chartTokens(scope.el); + const palette = chartPalette(scope.el); return variant === "calendar" ? buildCalendarOption(t, palette) : buildMatrixOption(t, palette); diff --git a/packages/ui/src/lib/charts/LineChart.svelte b/packages/ui/src/lib/charts/LineChart.svelte index 8505ea40..ea75b279 100644 --- a/packages/ui/src/lib/charts/LineChart.svelte +++ b/packages/ui/src/lib/charts/LineChart.svelte @@ -7,6 +7,7 @@ import ChartCanvas from "./ChartCanvas.svelte"; import { lineSeries } from "./series/line.js"; import { withChartTheme } from "./theme.js"; + import { useChartScope } from "./tokens.js"; import type { ChartSeries } from "./types.js"; interface Props { @@ -23,6 +24,8 @@ } let { series, categories, valueFormatter, area = false, height = "320px", class: className = "" }: Props = $props(); + // Tokens come from the theme island this chart sits in (the page when there is none). + const scope = useChartScope(); const option = $derived( withChartTheme({ @@ -42,7 +45,7 @@ ...(area ? { areaStyle: {} } : {}), }), ), - }), + }, scope.el), ); const gradientSeries = $derived(area ? series.map((_, i) => i) : []); diff --git a/packages/ui/src/lib/charts/PieChart.svelte b/packages/ui/src/lib/charts/PieChart.svelte index d70c6466..8e61c594 100644 --- a/packages/ui/src/lib/charts/PieChart.svelte +++ b/packages/ui/src/lib/charts/PieChart.svelte @@ -4,6 +4,7 @@ import ChartCanvas from "./ChartCanvas.svelte"; import { pieSeries } from "./series/pie.js"; import { withChartTheme } from "./theme.js"; + import { chartTokens, useChartScope } from "./tokens.js"; import type { ChartSlice } from "./types.js"; interface Props { @@ -19,6 +20,8 @@ } let { data, donut = false, rose = false, centerLabel, height = "300px", class: className = "" }: Props = $props(); + // Tokens come from the theme island this chart sits in (the page when there is none). + const scope = useChartScope(); const option = $derived( withChartTheme({ @@ -31,9 +34,9 @@ roseType: rose ? "radius" : undefined, label: rose ? { show: true, fontSize: 11 } : { show: false }, data, - }), + }, chartTokens(scope.el)), ], - }), + }, scope.el), ); diff --git a/packages/ui/src/lib/charts/RadarChart.svelte b/packages/ui/src/lib/charts/RadarChart.svelte index 7db4ee26..7452a0a0 100644 --- a/packages/ui/src/lib/charts/RadarChart.svelte +++ b/packages/ui/src/lib/charts/RadarChart.svelte @@ -3,6 +3,7 @@ import type { EChartsOption } from "echarts"; import ChartCanvas from "./ChartCanvas.svelte"; import { withChartTheme } from "./theme.js"; + import { useChartScope } from "./tokens.js"; import { chartTokens } from "./tokens.js"; import type { RadarIndicator, RadarSeries } from "./types.js"; @@ -14,9 +15,11 @@ } let { indicators, series, height = "340px", class: className = "" }: Props = $props(); + // Tokens come from the theme island this chart sits in (the page when there is none). + const scope = useChartScope(); const option = $derived.by(() => { - const t = chartTokens(); + const t = chartTokens(scope.el); return withChartTheme({ tooltip: { trigger: "item" }, legend: series.length > 1 ? { data: series.map((s) => s.name) } : undefined, @@ -41,7 +44,7 @@ })), }, ], - }); + }, scope.el); }); diff --git a/packages/ui/src/lib/charts/ScatterChart.svelte b/packages/ui/src/lib/charts/ScatterChart.svelte index 9f6921ac..75670958 100644 --- a/packages/ui/src/lib/charts/ScatterChart.svelte +++ b/packages/ui/src/lib/charts/ScatterChart.svelte @@ -4,6 +4,7 @@ import ChartCanvas from "./ChartCanvas.svelte"; import { scatterSeries, bubbleSymbolSize } from "./series/scatter.js"; import { withChartTheme } from "./theme.js"; + import { useChartScope } from "./tokens.js"; import type { ScatterSeries } from "./types.js"; interface Props { @@ -19,6 +20,8 @@ let { series, xName, yName, xFormatter, yFormatter, valueFormatter, height = "320px", class: className = "" }: Props = $props(); + // Tokens come from the theme island this chart sits in (the page when there is none). + const scope = useChartScope(); const option = $derived.by(() => { // Auto-detect bubble mode from any point with a 3rd dimension @@ -60,7 +63,7 @@ ...(isBubble ? { symbolSize: bubbleSymbolSize(6, 40, maxSize) } : {}), }), ), - }); + }, scope.el); }); diff --git a/packages/ui/src/lib/charts/Sparkline.svelte b/packages/ui/src/lib/charts/Sparkline.svelte index 1495190c..44844c13 100644 --- a/packages/ui/src/lib/charts/Sparkline.svelte +++ b/packages/ui/src/lib/charts/Sparkline.svelte @@ -5,6 +5,7 @@ import { lineSeries } from "./series/line.js"; import { barSeries } from "./series/bar.js"; import { withChartTheme } from "./theme.js"; + import { useChartScope } from "./tokens.js"; import { chartTokens } from "./tokens.js"; import type { SparklineTrend } from "./types.js"; @@ -18,9 +19,11 @@ } let { data, variant = "line", trend, color, height = "48px", class: className = "" }: Props = $props(); + // Tokens come from the theme island this chart sits in (the page when there is none). + const scope = useChartScope(); const option = $derived.by(() => { - const t = chartTokens(); + const t = chartTokens(scope.el); const resolvedColor = color ?? (trend === "up" ? t.success : trend === "down" ? t.error : t.textSecondary); @@ -48,7 +51,7 @@ tooltip: { show: false }, legend: { show: false }, series: [series], - }); + }, scope.el); }); diff --git a/packages/ui/src/lib/charts/charts.svelte.test.ts b/packages/ui/src/lib/charts/charts.svelte.test.ts index ed84cb80..b1726472 100644 --- a/packages/ui/src/lib/charts/charts.svelte.test.ts +++ b/packages/ui/src/lib/charts/charts.svelte.test.ts @@ -282,3 +282,18 @@ describe("charts", () => { expect(errors).not.toHaveBeenCalled(); }); }); + +describe("chart theme scope", () => { + it("charts inside a data-theme-scope island read that island's tokens, not :root", async () => { + const { default: ScopeHarness } = await import("./ChartScopeHarness.test.svelte"); + const screen = await render(ScopeHarness); + const probe = (id: string) => + screen.container.querySelector(`[data-testid="${id}"]`) as HTMLElement; + await expect.poll(() => probe("light").dataset.brand).toBeTruthy(); + // Different presets and schemes in one page: a chart reading :root would see one value in both. + expect(probe("light").dataset.brand).not.toBe(probe("dark").dataset.brand); + // The palette follows the island's own preset (ocean vs violet lead colors). + expect(probe("light").dataset.palette).toBe("#0ea5e9"); + expect(probe("dark").dataset.palette).toBe("#8b5cf6"); + }); +}); diff --git a/packages/ui/src/lib/charts/index.ts b/packages/ui/src/lib/charts/index.ts index 1011ca68..2b4722d9 100644 --- a/packages/ui/src/lib/charts/index.ts +++ b/packages/ui/src/lib/charts/index.ts @@ -1,5 +1,6 @@ // @codeatlasdev/ui/charts: needs the optional peer `echarts`. export { default as BarChart } from "./BarChart.svelte"; +export { default as ChartScope } from "./ChartScope.svelte"; export { default as GaugeChart } from "./GaugeChart.svelte"; export { default as HeatmapChart } from "./HeatmapChart.svelte"; export { default as LineChart } from "./LineChart.svelte"; diff --git a/packages/ui/src/lib/charts/palette.ts b/packages/ui/src/lib/charts/palette.ts index bf00b54d..83539200 100644 --- a/packages/ui/src/lib/charts/palette.ts +++ b/packages/ui/src/lib/charts/palette.ts @@ -20,14 +20,16 @@ const PRESET_PALETTES: Record = { const BRAND_REST = ["#f59e0b", "#22c55e", "#ef4444", "#a78bfa", "#06b6d4", "#f97316", "#84cc16"]; /** 8-color hex palette for the active preset: series colors, gradients, visualMap stops. */ -export function chartPalette(): string[] { +export function chartPalette(scope: Element | null = null): string[] { if (typeof document === "undefined") return PRESET_PALETTES.amber; trackTheme(); - const html = document.documentElement; - const builtin = PRESET_PALETTES[html.getAttribute("data-preset") as BuiltinPresetId]; + const el = scope ?? document.documentElement; + // The nearest `data-preset` wins, like the CSS: an island's own preset, else the page's. + const preset = el.closest("[data-preset]")?.getAttribute("data-preset") ?? ""; + const builtin = PRESET_PALETTES[preset as BuiltinPresetId]; if (builtin) return builtin; // Brand presets (ThemeOptions.presets) publish their hex as --ca-chart-brand. - const brand = getComputedStyle(html).getPropertyValue("--ca-chart-brand").trim(); + const brand = getComputedStyle(el).getPropertyValue("--ca-chart-brand").trim(); return brand ? [brand, ...BRAND_REST] : PRESET_PALETTES.amber; } diff --git a/packages/ui/src/lib/charts/theme.ts b/packages/ui/src/lib/charts/theme.ts index 44cf2fc6..e6a246e7 100644 --- a/packages/ui/src/lib/charts/theme.ts +++ b/packages/ui/src/lib/charts/theme.ts @@ -44,14 +44,18 @@ export function chartTooltip(t: ChartTokens = chartTokens()): EChartsOption["too export type AxisPointerType = "line" | "shadow" | "none"; -export function chartAxisPointer(type: AxisPointerType, t: ChartTokens = chartTokens()): object { +export function chartAxisPointer( + type: AxisPointerType, + t: ChartTokens = chartTokens(), + scope: Element | null = null +): object { if (type === "none") return {}; if (type === "shadow") { return { type: "shadow", shadowStyle: { color: "rgba(150,150,150,0.06)" } }; } return { type: "line", - lineStyle: { color: withAlphaFirst(t), type: "dashed", width: 1 }, + lineStyle: { color: withAlphaFirst(t, scope), type: "dashed", width: 1 }, label: { backgroundColor: t.glassBg, borderColor: t.glassBorder, @@ -64,8 +68,8 @@ export function chartAxisPointer(type: AxisPointerType, t: ChartTokens = chartTo }; } -function withAlphaFirst(_t: ChartTokens): string { - const [first] = chartPalette(); +function withAlphaFirst(_t: ChartTokens, scope: Element | null = null): string { + const [first] = chartPalette(scope); return hexToRgba(first, 0.45); } @@ -120,8 +124,9 @@ export function chartLegend(t: ChartTokens = chartTokens()): EChartsOption["lege * - any series with `type: 'bar'` → shadow * - everything else → line crosshair */ -export function withChartTheme(option: EChartsOption): EChartsOption { - const t = chartTokens(); +export function withChartTheme(option: EChartsOption, scope: Element | null = null): EChartsOption { + const t = chartTokens(scope); + const palette = chartPalette(scope); const ax = chartAxis(t); const callerTrigger = (option.tooltip as Record | undefined)?.trigger; @@ -156,11 +161,11 @@ export function withChartTheme(option: EChartsOption): EChartsOption { const base: EChartsOption = { backgroundColor: "transparent", - color: chartPalette(), + color: palette, textStyle: { fontFamily: t.fontSans, fontSize: 12, color: t.textPrimary }, tooltip: isItemTrigger ? { ...chartTooltip(t), trigger: "item" } - : { ...chartTooltip(t), trigger: "axis", axisPointer: chartAxisPointer(apType, t) }, + : { ...chartTooltip(t), trigger: "axis", axisPointer: chartAxisPointer(apType, t, scope) }, legend: chartLegend(t), ...cartesianDefaults, }; diff --git a/packages/ui/src/lib/charts/tokens.ts b/packages/ui/src/lib/charts/tokens.ts index b43d7d5b..e3c5f4b4 100644 --- a/packages/ui/src/lib/charts/tokens.ts +++ b/packages/ui/src/lib/charts/tokens.ts @@ -2,8 +2,33 @@ // Reads the live design system (dark/light + 8 presets) at call time so every // chart option is always in sync with the current theme. Internal only. +import { getContext, setContext } from "svelte"; import { colorScheme } from "../stores/color-scheme.svelte.js"; +// ─── Theme scope ────────────────────────────────────────────────────────────── +// Charts are canvas: they cannot use CSS variables, so tokens are read once per option build with +// getComputedStyle. Read from `:root`, a chart inside a `data-theme-scope` / `data-preset` island +// painted the page's colors instead of the island's. A scope is the element whose computed style +// holds the tokens; `ThemeIsland` (and `ChartScope`) provide theirs by context, and every public +// chart reads it at init. No scope means ``, exactly as before. + +const SCOPE = Symbol("ca-chart-scope"); + +/** Reactive holder of the element charts read tokens from. */ +export interface ChartScopeRef { + el: HTMLElement | null; +} + +/** Provide `ref` to every chart rendered below the calling component. Call during component init. */ +export function setChartScope(ref: ChartScopeRef): void { + setContext(SCOPE, ref); +} + +/** The scope provided above this component, or an empty one (`el: null` = the page). Call during init. */ +export function useChartScope(): ChartScopeRef { + return getContext(SCOPE) ?? { el: null }; +} + /** * CSS vars aren't reactive. Reading the scheme here makes any `$derived` that builds a chart * option re-run on theme/preset/style changes (the store updates the DOM synchronously first). @@ -14,10 +39,14 @@ export function trackTheme(): void { void colorScheme.style; } -function v(name: string, fallback = ""): string { +function v(name: string, fallback: string, scope: Element | null): string { if (typeof document === "undefined") return fallback; trackTheme(); - return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; + return ( + getComputedStyle(scope ?? document.documentElement) + .getPropertyValue(name) + .trim() || fallback + ); } export interface ChartTokens { @@ -41,26 +70,31 @@ export interface ChartTokens { fontSans: string; } -export function chartTokens(): ChartTokens { +/** Token snapshot read from `scope` (an island) or, without one, from ``. */ +export function chartTokens(scope: Element | null = null): ChartTokens { return { - brand: v("--ca-brand-main", "#f59e0b"), - brandLight: v("--ca-brand-light", "#fcd34d"), - textPrimary: v("--ca-text-primary", "#0f172a"), - textSecondary: v("--ca-text-secondary", "#475569"), - textDisabled: v("--ca-text-disabled", "#94a3b8"), - success: v("--ca-semantic-success", "#22c55e"), - error: v("--ca-semantic-error", "#ef4444"), - warning: v("--ca-semantic-warning", "#f59e0b"), - info: v("--ca-semantic-info", "#3b82f6"), - glassBg: v("--ca-glass-bg", "rgba(255,255,255,0.55)"), - glassBorder: v("--ca-glass-border", "rgba(255,255,255,0.25)"), - glassShadow: v("--ca-glass-shadow", "0 8px 32px rgba(0,0,0,0.12)"), - blur: v("--ca-blur-regular", "blur(16px)"), - outlineSubtle: v("--ca-outline-subtle", "rgba(0,0,0,0.07)"), - actionHover: v("--ca-action-hover", "rgba(0,0,0,0.04)"), - radiusMd: v("--ca-radius-md", "12px"), - radiusLg: v("--ca-radius-lg", "16px"), - fontSans: v("--ca-font-sans", "Inter,system-ui,-apple-system,BlinkMacSystemFont,sans-serif"), + brand: v("--ca-brand-main", "#f59e0b", scope), + brandLight: v("--ca-brand-light", "#fcd34d", scope), + textPrimary: v("--ca-text-primary", "#0f172a", scope), + textSecondary: v("--ca-text-secondary", "#475569", scope), + textDisabled: v("--ca-text-disabled", "#94a3b8", scope), + success: v("--ca-semantic-success", "#22c55e", scope), + error: v("--ca-semantic-error", "#ef4444", scope), + warning: v("--ca-semantic-warning", "#f59e0b", scope), + info: v("--ca-semantic-info", "#3b82f6", scope), + glassBg: v("--ca-glass-bg", "rgba(255,255,255,0.55)", scope), + glassBorder: v("--ca-glass-border", "rgba(255,255,255,0.25)", scope), + glassShadow: v("--ca-glass-shadow", "0 8px 32px rgba(0,0,0,0.12)", scope), + blur: v("--ca-blur-regular", "blur(16px)", scope), + outlineSubtle: v("--ca-outline-subtle", "rgba(0,0,0,0.07)", scope), + actionHover: v("--ca-action-hover", "rgba(0,0,0,0.04)", scope), + radiusMd: v("--ca-radius-md", "12px", scope), + radiusLg: v("--ca-radius-lg", "16px", scope), + fontSans: v( + "--ca-font-sans", + "Inter,system-ui,-apple-system,BlinkMacSystemFont,sans-serif", + scope + ), }; } diff --git a/packages/ui/src/lib/components/ColorField/ColorField.svelte b/packages/ui/src/lib/components/ColorField/ColorField.svelte new file mode 100644 index 00000000..14a82c52 --- /dev/null +++ b/packages/ui/src/lib/components/ColorField/ColorField.svelte @@ -0,0 +1,223 @@ + + +
+ { + editing = true; + error = ''; + }} + onchange={commit} + onblur={() => { + if (editing && parseColorInput(text)) commit(text); + else if (editing && text.trim() === '') { + editing = false; + text = value; + } + }} + > + {#snippet leading()} + + {/snippet} + {#snippet trailing()} + + {/snippet} + + {#if ratio !== null && level} +

+ {ratio.toFixed(1)}:1 + {passes ? t().colorField.pass(level) : t().colorField.fail(minRatio)} +

+ {/if} +
+ + diff --git a/packages/ui/src/lib/components/ColorField/ColorField.svelte.test.ts b/packages/ui/src/lib/components/ColorField/ColorField.svelte.test.ts new file mode 100644 index 00000000..78e7ff47 --- /dev/null +++ b/packages/ui/src/lib/components/ColorField/ColorField.svelte.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; +import { render } from "vitest-browser-svelte"; +import { checkA11y } from "../../../../tests/a11y"; +import ColorFieldHarness from "./ColorFieldHarness.test.svelte"; + +describe("ColorField", () => { + it("commits a typed oklch() as typed and passes axe", async () => { + const screen = await render(ColorFieldHarness, { against: "#ffffff" }); + const input = screen.getByRole("textbox", { name: "Brand" }); + await input.fill("oklch(0.5 0.2 30)"); + await userEvent.keyboard("{Enter}"); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("oklch(0.5 0.2 30)"); + expect(await checkA11y(screen.container)).toHaveNoViolations(); + }); + + it("rejects an invalid color with an error and never writes it to value", async () => { + const onchange = vi.fn(); + const screen = await render(ColorFieldHarness, { onchange }); + await screen.getByRole("textbox", { name: "Brand" }).fill("nope"); + await userEvent.keyboard("{Enter}"); + await expect + .element(screen.getByRole("alert")) + .toHaveTextContent("Use #rrggbb, rgb() or oklch()"); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("#2b5fd9"); + expect(onchange).not.toHaveBeenCalled(); + }); + + it("shows the contrast ratio, and says so in words when it fails", async () => { + const pass = await render(ColorFieldHarness, { initial: "#595959", against: "#ffffff" }); + await expect.element(pass.getByText("7.0:1", { exact: true })).toBeInTheDocument(); + await expect.element(pass.getByText("Passes AAA")).toBeInTheDocument(); + pass.unmount(); + // 4.48:1 prints as "4.5:1" but is under the threshold: the verdict must follow the real ratio. + const edge = await render(ColorFieldHarness, { initial: "#777777", against: "#ffffff" }); + await expect.element(edge.getByText("Below 4.5:1")).toBeInTheDocument(); + edge.unmount(); + const fail = await render(ColorFieldHarness, { initial: "#cccccc", against: "#ffffff" }); + await expect.element(fail.getByText("Below 4.5:1")).toBeInTheDocument(); + }); + + it("the native picker writes hex and the field follows", async () => { + const screen = await render(ColorFieldHarness); + const native = screen.container.querySelector('input[type="color"]'); + if (!native) throw new Error("native color input missing"); + native.value = "#ff0000"; + native.dispatchEvent(new Event("input", { bubbles: true })); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("#ff0000"); + await expect.element(screen.getByRole("textbox", { name: "Brand" })).toHaveValue("#ff0000"); + }); +}); diff --git a/packages/ui/src/lib/components/ColorField/ColorFieldHarness.test.svelte b/packages/ui/src/lib/components/ColorField/ColorFieldHarness.test.svelte new file mode 100644 index 00000000..259eab3c --- /dev/null +++ b/packages/ui/src/lib/components/ColorField/ColorFieldHarness.test.svelte @@ -0,0 +1,10 @@ + + + +{value} diff --git a/packages/ui/src/lib/components/Slider/Slider.svelte b/packages/ui/src/lib/components/Slider/Slider.svelte index ec243bb4..e933c2e3 100644 --- a/packages/ui/src/lib/components/Slider/Slider.svelte +++ b/packages/ui/src/lib/components/Slider/Slider.svelte @@ -27,6 +27,8 @@ step?: number; disabled?: boolean; class?: string; + /** Accessible name of the thumb(s). Without it a thumb is announced as "Value 40". A range appends "minimum"/"maximum". */ + label?: string; /** Called on change. Always receives array regardless of single/range mode. */ onchange?: (value: number[]) => void; /** Plays `tick` for each step the user crosses (a cue name replaces it); off under reduced motion. Needs `@codeatlasdev/ui/sound`. */ @@ -41,6 +43,7 @@ step = 1, disabled = false, class: className = '', + label, onchange, sound, }: Props = $props(); @@ -202,7 +205,7 @@ role="slider" tabindex={disabled ? -1 : 0} class="ca-slider__thumb" - aria-label={t().slider.value(v)} + aria-label={label ? (internal.length > 1 ? `${label} ${i === 0 ? t().slider.minimum : t().slider.maximum}` : label) : t().slider.value(v)} aria-valuemin={min} aria-valuemax={max} aria-valuenow={v} diff --git a/packages/ui/src/lib/components/SliderField/SliderField.svelte b/packages/ui/src/lib/components/SliderField/SliderField.svelte new file mode 100644 index 00000000..8c9a7b51 --- /dev/null +++ b/packages/ui/src/lib/components/SliderField/SliderField.svelte @@ -0,0 +1,134 @@ + + +
+
+ {label} + + (editing = true)} + onblur={commit} + onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && commit()} + /> + +
+ set(v[0] ?? value)} /> + {#if description}

{description}

{/if} +
+ + diff --git a/packages/ui/src/lib/components/SliderField/SliderField.svelte.test.ts b/packages/ui/src/lib/components/SliderField/SliderField.svelte.test.ts new file mode 100644 index 00000000..01825d6d --- /dev/null +++ b/packages/ui/src/lib/components/SliderField/SliderField.svelte.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; +import { render } from "vitest-browser-svelte"; +import { checkA11y } from "../../../../tests/a11y"; +import SliderFieldHarness from "./SliderFieldHarness.test.svelte"; + +describe("SliderField", () => { + it("names the slider and the number field after the label and passes axe", async () => { + const screen = await render(SliderFieldHarness); + await expect.element(screen.getByRole("slider", { name: "Radius" })).toBeInTheDocument(); + await expect.element(screen.getByRole("textbox", { name: "Radius" })).toHaveValue("0.80"); + expect(await checkA11y(screen.container)).toHaveNoViolations(); + }); + + it("a typed value is clamped and snapped to step on Enter", async () => { + const screen = await render(SliderFieldHarness); + const input = screen.getByRole("textbox", { name: "Radius" }); + await input.fill("1.234"); + await userEvent.keyboard("{Enter}"); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("1.25"); + await input.fill("9"); + await userEvent.keyboard("{Enter}"); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("2"); + await expect.element(input).toHaveValue("2.00"); + }); + + it("arrow keys on the slider move by step and update the number", async () => { + const screen = await render(SliderFieldHarness, { initial: 1 }); + screen.getByRole("slider", { name: "Radius" }).element().focus(); + await userEvent.keyboard("{ArrowRight}"); + await expect.element(screen.getByTestId("bound")).toHaveTextContent("1.05"); + await expect.element(screen.getByRole("textbox", { name: "Radius" })).toHaveValue("1.05"); + }); +}); diff --git a/packages/ui/src/lib/components/SliderField/SliderFieldHarness.test.svelte b/packages/ui/src/lib/components/SliderField/SliderFieldHarness.test.svelte new file mode 100644 index 00000000..cbe1971e --- /dev/null +++ b/packages/ui/src/lib/components/SliderField/SliderFieldHarness.test.svelte @@ -0,0 +1,10 @@ + + + +{value} diff --git a/packages/ui/src/lib/components/ThemeIsland/ThemeIsland.svelte b/packages/ui/src/lib/components/ThemeIsland/ThemeIsland.svelte index f0c96fe5..d0606843 100644 --- a/packages/ui/src/lib/components/ThemeIsland/ThemeIsland.svelte +++ b/packages/ui/src/lib/components/ThemeIsland/ThemeIsland.svelte @@ -1,5 +1,6 @@ + +
+ +

value: {brand}

+
+ + diff --git a/site/public/api/v1/en/p/990.txt b/site/public/api/v1/en/p/990.txt new file mode 100644 index 00000000..c42bfb78 --- /dev/null +++ b/site/public/api/v1/en/p/990.txt @@ -0,0 +1,14 @@ +Use @codeatlasdev/ui demo #990 "Brand color" (inputs/color-field). +What it shows: Color field with a swatch, a native picker, a text input and a live WCAG contrast readout against white. When to use: Picking a brand or accent color where contrast matters. +Spec: +- ` + (function () { + try { + var t = JSON.parse(localStorage.getItem("ca-site-studio") || "null"); + if (!t || t.v !== 1 || typeof t.css !== "string" || !t.css) return; + var s = document.createElement("style"); + s.id = "ca-site-studio"; + s.textContent = t.css; + document.head.appendChild(s); + var h = document.documentElement; + if (/^[\w-]+$/.test(t.preset)) h.setAttribute("data-preset", t.preset); + if (/^[\w-]+$/.test(t.style)) h.setAttribute("data-style", t.style); + // Stylesheets the bundler links after this script would sit later in the cascade and win + // at equal specificity: move the theme to the end once the head is complete. + document.addEventListener("DOMContentLoaded", function () { + document.head.appendChild(s); + }); + } catch (e) {} + })(); + diff --git a/site/src/components/theme/Controls.svelte b/site/src/components/theme/Controls.svelte deleted file mode 100644 index 438ca9f5..00000000 --- a/site/src/components/theme/Controls.svelte +++ /dev/null @@ -1,187 +0,0 @@ - - -{#snippet label(text: string, value?: string | number)} -
{text}{#if value !== undefined}{value}{/if}
-{/snippet} - -
-
-

Design style

- ({ value: s.id, label: s.label, description: s.description }))} - onchange={(v) => selectStyle(v as DesignStyleId)} - /> -
- -
-

Color

- {@render label('Scheme preview')} - setScheme(v as 'light' | 'dark')} - items={[ - { value: 'light', label: 'Light', icon: Sun }, - { value: 'dark', label: 'Dark', icon: Moon }, - ]} - /> - - {@render label('Built-in palettes', activePreset)} -
- {#each THEME_PRESETS as p (p.id)} - patch({ presets: [], defaultPreset: p.id })} - /> - {/each} -
-
- -
-

Shape and density

- {@render label('Surface style')} - patch({ motion: { ...motion, easing: v as never } })} /> - {@render label('Icon stroke', def.iconStroke ?? 1.75)} - patch({ iconStroke: v })} /> - {@render label('Paper')} - patch({ paper: v as never })} items={opts(PAPERS)} /> - {@render label('Noise', def.noise ?? 0)} - patch({ noise: v })} /> -
- - -
- - diff --git a/site/src/components/theme/Preview.svelte b/site/src/components/theme/Preview.svelte deleted file mode 100644 index 08840924..00000000 --- a/site/src/components/theme/Preview.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - - - {@html ``} - - - -
- - {#snippet headerActions()} - - {/snippet} -
-

Invite your team

-

- Every control in this card reads its radius, density, border, type and motion from the - theme you are editing. -

- - - -
- - - - -
-
- - - -
-
-
- - - - Changes apply to this preview only. - - - - - MemberRoleStatus - - - {#each rows as r} - - {r.who} - {r.role} - - - {/each} - - - -
- - diff --git a/site/src/components/theme/ThemeEditor.svelte b/site/src/components/theme/ThemeEditor.svelte deleted file mode 100644 index 30d44d75..00000000 --- a/site/src/components/theme/ThemeEditor.svelte +++ /dev/null @@ -1,111 +0,0 @@ - - -{#snippet head(title: string, sub?: string)} -
-
- {title} - {#if sub}{sub}{/if} -
-
-{/snippet} - -{#snippet controls()} - -{/snippet} -{#snippet preview()} - -{/snippet} -{#snippet code()} -
-{/snippet} - -{#if wide} -
-
- {@render head('Controls', DESIGN_STYLES[state.style].label)} -
{@render controls()}
-
-
- {@render head('Preview', 'Scoped to this panel')} -
{@render preview()}
-
-
- {@render head('Code', 'Paste into src/lib/theme.ts')} -
{@render code()}
-
-
-{:else} - -
- - Controls - Preview - Code - -
- {@render controls()} - {@render preview()} - {@render code()} -
-{/if} - - diff --git a/site/src/components/theme/ThemeStudio.svelte b/site/src/components/theme/ThemeStudio.svelte new file mode 100644 index 00000000..50d8bb4e --- /dev/null +++ b/site/src/components/theme/ThemeStudio.svelte @@ -0,0 +1,307 @@ + + + + +{#snippet toolbar()} + +{/snippet} + +{#snippet stageBar()} +
+ +
+