@handamade/psi-tokens 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/dist/cli/psi-theme.d.ts +2 -0
- package/dist/cli/psi-theme.js +56 -0
- package/dist/components/button.d.ts +7 -0
- package/dist/components/button.js +76 -0
- package/dist/components/card.d.ts +2 -0
- package/dist/components/card.js +7 -0
- package/dist/components/checkbox.d.ts +2 -0
- package/dist/components/checkbox.js +13 -0
- package/dist/components/control.d.ts +21 -0
- package/dist/components/control.js +59 -0
- package/dist/components/description-list.d.ts +8 -0
- package/dist/components/description-list.js +13 -0
- package/dist/components/dialog.d.ts +6 -0
- package/dist/components/dialog.js +13 -0
- package/dist/components/field.d.ts +3 -0
- package/dist/components/field.js +9 -0
- package/dist/components/input.d.ts +2 -0
- package/dist/components/input.js +25 -0
- package/dist/components/media.d.ts +3 -0
- package/dist/components/media.js +5 -0
- package/dist/components/menu.d.ts +7 -0
- package/dist/components/menu.js +19 -0
- package/dist/components/navbar.d.ts +3 -0
- package/dist/components/navbar.js +11 -0
- package/dist/components/panel.d.ts +5 -0
- package/dist/components/panel.js +10 -0
- package/dist/components/registry.d.ts +1 -0
- package/dist/components/registry.js +46 -0
- package/dist/components/select.d.ts +2 -0
- package/dist/components/select.js +38 -0
- package/dist/components/surface.d.ts +5 -0
- package/dist/components/surface.js +10 -0
- package/dist/components/switch.d.ts +2 -0
- package/dist/components/switch.js +8 -0
- package/dist/components/table.d.ts +12 -0
- package/dist/components/table.js +30 -0
- package/dist/components/tabs.d.ts +14 -0
- package/dist/components/tabs.js +26 -0
- package/dist/components/tag.d.ts +4 -0
- package/dist/components/tag.js +26 -0
- package/dist/components/toast.d.ts +19 -0
- package/dist/components/toast.js +28 -0
- package/dist/components/toolbar.d.ts +13 -0
- package/dist/components/toolbar.js +15 -0
- package/dist/components/tooltip.d.ts +2 -0
- package/dist/components/tooltip.js +7 -0
- package/dist/contrast-matrix.d.ts +34 -0
- package/dist/contrast-matrix.js +130 -0
- package/dist/dsl/builders.d.ts +7 -0
- package/dist/dsl/builders.js +25 -0
- package/dist/dsl/resolver.d.ts +3 -0
- package/dist/dsl/resolver.js +114 -0
- package/dist/dsl/types.d.ts +59 -0
- package/dist/dsl/types.js +2 -0
- package/dist/dsl/validator.d.ts +12 -0
- package/dist/dsl/validator.js +97 -0
- package/dist/emit/css.d.ts +30 -0
- package/dist/emit/css.js +120 -0
- package/dist/gamut.d.ts +4 -0
- package/dist/gamut.js +44 -0
- package/dist/generate/derive.d.ts +20 -0
- package/dist/generate/derive.js +232 -0
- package/dist/generate/dictionaries.d.ts +18 -0
- package/dist/generate/dictionaries.js +46 -0
- package/dist/generate/index.d.ts +6 -0
- package/dist/generate/index.js +6 -0
- package/dist/generate/palette.d.ts +7 -0
- package/dist/generate/palette.js +93 -0
- package/dist/generate/prompt.d.ts +7 -0
- package/dist/generate/prompt.js +62 -0
- package/dist/generate/serialize.d.ts +10 -0
- package/dist/generate/serialize.js +167 -0
- package/dist/generate/solve.d.ts +36 -0
- package/dist/generate/solve.js +56 -0
- package/dist/generate/types.d.ts +24 -0
- package/dist/generate/types.js +48 -0
- package/dist/scope-gate.d.ts +15 -0
- package/dist/scope-gate.js +87 -0
- package/dist/scope-map.json +2 -1
- package/dist/scopes.d.ts +6 -0
- package/dist/scopes.js +88 -0
- package/dist/theme/gate.d.ts +19 -0
- package/dist/theme/gate.js +44 -0
- package/dist/theme/index.d.ts +16 -0
- package/dist/theme/index.js +23 -0
- package/dist/themes/customers/acme.d.ts +8 -0
- package/dist/themes/customers/acme.js +31 -0
- package/dist/themes/customers/ember.d.ts +13 -0
- package/dist/themes/customers/ember.js +52 -0
- package/dist/themes/customers/index.d.ts +28 -0
- package/dist/themes/customers/index.js +23 -0
- package/dist/themes/dark.d.ts +2 -0
- package/dist/themes/dark.js +55 -0
- package/dist/themes/light.d.ts +2 -0
- package/dist/themes/light.js +56 -0
- package/llms.txt +15 -0
- package/package.json +14 -2
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Table component tokens (--psi-table-*) — D62. Pure indirection, same
|
|
2
|
+
* posture as menu.ts (D53) and panel.ts: the container aliases the shared
|
|
3
|
+
* surface family, row states reuse Menu's item recipe, and per-size geometry
|
|
4
|
+
* aliases the D54/D55 control ramp so a 32px row and a 32px Button are the
|
|
5
|
+
* same 32px from one source. A brand retuning --psi-surface-* or the control
|
|
6
|
+
* ramp gets Table for free.
|
|
7
|
+
*
|
|
8
|
+
* row-bg-selected is --psi-fill-tint-accent, the same wash Button and Tag use
|
|
9
|
+
* for accent-subtle. Note --psi-fill-accent2 does NOT exist; do not write it.
|
|
10
|
+
* Geometry keys carry no bg/fg/border segment, so keyGroup() returns undefined
|
|
11
|
+
* and they stay out of scope-map.json and both D46 gates. */
|
|
12
|
+
export const tableVars = {
|
|
13
|
+
bg: "var(--psi-surface-bg)",
|
|
14
|
+
border: "var(--psi-surface-border)",
|
|
15
|
+
radius: "var(--psi-surface-radius)",
|
|
16
|
+
fg: "var(--psi-fg-primary)",
|
|
17
|
+
"header-fg": "var(--psi-fg-secondary)",
|
|
18
|
+
"cell-border": "var(--psi-border-faint)",
|
|
19
|
+
"row-bg": "transparent",
|
|
20
|
+
"row-bg-hover": "var(--psi-fill-neutral3)",
|
|
21
|
+
"row-bg-selected": "var(--psi-fill-tint-accent)",
|
|
22
|
+
"sort-indicator-fg": "var(--psi-fg-accent)",
|
|
23
|
+
"focus-ring": "var(--psi-border-focus)",
|
|
24
|
+
"32-row-height": "var(--psi-control-32-height)",
|
|
25
|
+
"40-row-height": "var(--psi-control-40-height)",
|
|
26
|
+
"48-row-height": "var(--psi-control-48-height)",
|
|
27
|
+
"32-cell-padding-x": "var(--psi-control-32-padding-inline)",
|
|
28
|
+
"40-cell-padding-x": "var(--psi-control-40-padding-inline)",
|
|
29
|
+
"48-cell-padding-x": "var(--psi-control-48-padding-inline)",
|
|
30
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Tabs component tokens (--psi-tabs-*) — D67. Pure indirection, same posture
|
|
2
|
+
* as menu.ts (D53), table.ts (D62) and toast.ts (D64): colour aliases the
|
|
3
|
+
* semantic layer, geometry aliases the D54/D55 control ramp so a 32px tab and
|
|
4
|
+
* a 32px Button are the same 32px from one source.
|
|
5
|
+
*
|
|
6
|
+
* No new contrast pairs: fgAccent and fgSecondary on bgPrimary are already in
|
|
7
|
+
* wcagAAPairs, and the geometry keys carry no bg/fg/border segment so
|
|
8
|
+
* keyGroup() returns undefined and they stay out of both D46 gates.
|
|
9
|
+
*
|
|
10
|
+
* fg-disabled is deliberately NOT contrast-gated. Disabled text is exempt from
|
|
11
|
+
* WCAG 1.4.3, and gating it would force a contrast that defeats the
|
|
12
|
+
* affordance — the same reasoning behind Menu's item-fg-disabled, which binds
|
|
13
|
+
* the same token. */
|
|
14
|
+
export declare const tabsVars: Record<string, string>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Tabs component tokens (--psi-tabs-*) — D67. Pure indirection, same posture
|
|
2
|
+
* as menu.ts (D53), table.ts (D62) and toast.ts (D64): colour aliases the
|
|
3
|
+
* semantic layer, geometry aliases the D54/D55 control ramp so a 32px tab and
|
|
4
|
+
* a 32px Button are the same 32px from one source.
|
|
5
|
+
*
|
|
6
|
+
* No new contrast pairs: fgAccent and fgSecondary on bgPrimary are already in
|
|
7
|
+
* wcagAAPairs, and the geometry keys carry no bg/fg/border segment so
|
|
8
|
+
* keyGroup() returns undefined and they stay out of both D46 gates.
|
|
9
|
+
*
|
|
10
|
+
* fg-disabled is deliberately NOT contrast-gated. Disabled text is exempt from
|
|
11
|
+
* WCAG 1.4.3, and gating it would force a contrast that defeats the
|
|
12
|
+
* affordance — the same reasoning behind Menu's item-fg-disabled, which binds
|
|
13
|
+
* the same token. */
|
|
14
|
+
export const tabsVars = {
|
|
15
|
+
fg: "var(--psi-fg-secondary)",
|
|
16
|
+
"fg-selected": "var(--psi-fg-accent)",
|
|
17
|
+
"fg-disabled": "var(--psi-fg-quaternary)",
|
|
18
|
+
"bg-hover": "var(--psi-fill-neutral3)",
|
|
19
|
+
indicator: "var(--psi-fill-accent)",
|
|
20
|
+
"list-border": "var(--psi-border-faint)",
|
|
21
|
+
"focus-ring": "var(--psi-border-focus)",
|
|
22
|
+
"32-height": "var(--psi-control-32-height)",
|
|
23
|
+
"40-height": "var(--psi-control-40-height)",
|
|
24
|
+
"32-padding-x": "var(--psi-control-32-padding-inline)",
|
|
25
|
+
"40-padding-x": "var(--psi-control-40-padding-inline)",
|
|
26
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Tag component tokens (--psi-tag-*). Solid variants map to fill + foreground
|
|
2
|
+
* pairs; subtle variants add tint fills. Warning label uses fg-static-black
|
|
3
|
+
* (D22) instead of fg-primary for contrast and visual emphasis. */
|
|
4
|
+
export declare const tagVars: Record<string, string>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Tag component tokens (--psi-tag-*). Solid variants map to fill + foreground
|
|
2
|
+
* pairs; subtle variants add tint fills. Warning label uses fg-static-black
|
|
3
|
+
* (D22) instead of fg-primary for contrast and visual emphasis. */
|
|
4
|
+
export const tagVars = {
|
|
5
|
+
"neutral-bg": "var(--psi-fill-neutral3)",
|
|
6
|
+
"neutral-fg": "var(--psi-fg-primary)",
|
|
7
|
+
"accent-bg": "var(--psi-fill-accent)",
|
|
8
|
+
"accent-fg": "var(--psi-fg-on-accent)",
|
|
9
|
+
"success-bg": "var(--psi-fill-success)",
|
|
10
|
+
"success-fg": "var(--psi-fg-static-white)",
|
|
11
|
+
"warning-bg": "var(--psi-fill-warning)",
|
|
12
|
+
"warning-fg": "var(--psi-fg-static-black)",
|
|
13
|
+
"danger-bg": "var(--psi-fill-danger)",
|
|
14
|
+
"danger-fg": "var(--psi-fg-static-white)",
|
|
15
|
+
"neutral-subtle-bg": "var(--psi-fill-neutral2)",
|
|
16
|
+
"neutral-subtle-fg": "var(--psi-fg-secondary)",
|
|
17
|
+
"accent-subtle-bg": "var(--psi-fill-tint-accent)",
|
|
18
|
+
"accent-subtle-fg": "var(--psi-fg-accent)",
|
|
19
|
+
"success-subtle-bg": "var(--psi-fill-tint-success)",
|
|
20
|
+
"success-subtle-fg": "var(--psi-fg-success)",
|
|
21
|
+
"warning-subtle-bg": "var(--psi-fill-tint-warning)",
|
|
22
|
+
"warning-subtle-fg": "var(--psi-fg-warning)",
|
|
23
|
+
"danger-subtle-bg": "var(--psi-fill-tint-danger)",
|
|
24
|
+
"danger-subtle-fg": "var(--psi-fg-danger)",
|
|
25
|
+
"focus-ring": "var(--psi-border-focus)",
|
|
26
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Toast component tokens (--psi-toast-*) — D64/D65. Pure indirection, same
|
|
2
|
+
* posture as menu.ts (D53) and table.ts (D62): the card aliases the shared
|
|
3
|
+
* surface family, so a brand retuning --psi-surface-* gets Toast for free.
|
|
4
|
+
*
|
|
5
|
+
* The variant lives in the status icon's colour only — Toast is an elevated
|
|
6
|
+
* surface card, not a tinted one (a stack of three saturated cards is the
|
|
7
|
+
* loudest thing on the screen, and a tint fights the elevated-surface language
|
|
8
|
+
* every other overlay speaks). The tinted alternative was measured and also
|
|
9
|
+
* passes contrast; it is rejected on design grounds, not on the gate.
|
|
10
|
+
*
|
|
11
|
+
* Note there is NO shadow key: --psi-shadow-* does not exist in any form, and
|
|
12
|
+
* neither Menu nor Dialog declares a box-shadow. Psi's elevated surfaces are
|
|
13
|
+
* border + background. Do not write --psi-shadow-overlay.
|
|
14
|
+
*
|
|
15
|
+
* The three semantic icon foregrounds sit on bgSecondary (what --psi-surface-bg
|
|
16
|
+
* resolves to), so contrast-matrix.ts gates fgSuccess/fgWarning/fgDanger there
|
|
17
|
+
* as well as on bgPrimary. Tightest measured margin is 5.87 (light) against a
|
|
18
|
+
* 4.5 floor. */
|
|
19
|
+
export declare const toastVars: Record<string, string>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Toast component tokens (--psi-toast-*) — D64/D65. Pure indirection, same
|
|
2
|
+
* posture as menu.ts (D53) and table.ts (D62): the card aliases the shared
|
|
3
|
+
* surface family, so a brand retuning --psi-surface-* gets Toast for free.
|
|
4
|
+
*
|
|
5
|
+
* The variant lives in the status icon's colour only — Toast is an elevated
|
|
6
|
+
* surface card, not a tinted one (a stack of three saturated cards is the
|
|
7
|
+
* loudest thing on the screen, and a tint fights the elevated-surface language
|
|
8
|
+
* every other overlay speaks). The tinted alternative was measured and also
|
|
9
|
+
* passes contrast; it is rejected on design grounds, not on the gate.
|
|
10
|
+
*
|
|
11
|
+
* Note there is NO shadow key: --psi-shadow-* does not exist in any form, and
|
|
12
|
+
* neither Menu nor Dialog declares a box-shadow. Psi's elevated surfaces are
|
|
13
|
+
* border + background. Do not write --psi-shadow-overlay.
|
|
14
|
+
*
|
|
15
|
+
* The three semantic icon foregrounds sit on bgSecondary (what --psi-surface-bg
|
|
16
|
+
* resolves to), so contrast-matrix.ts gates fgSuccess/fgWarning/fgDanger there
|
|
17
|
+
* as well as on bgPrimary. Tightest measured margin is 5.87 (light) against a
|
|
18
|
+
* 4.5 floor. */
|
|
19
|
+
export const toastVars = {
|
|
20
|
+
bg: "var(--psi-surface-bg)",
|
|
21
|
+
border: "var(--psi-surface-border)",
|
|
22
|
+
radius: "var(--psi-surface-radius)",
|
|
23
|
+
fg: "var(--psi-fg-primary)",
|
|
24
|
+
"icon-fg-neutral": "var(--psi-fg-secondary)",
|
|
25
|
+
"icon-fg-success": "var(--psi-fg-success)",
|
|
26
|
+
"icon-fg-warning": "var(--psi-fg-warning)",
|
|
27
|
+
"icon-fg-danger": "var(--psi-fg-danger)",
|
|
28
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Toolbar component tokens (--psi-toolbar-*) — D77.
|
|
2
|
+
*
|
|
3
|
+
* A deliberate default width for Toolbar's direct, unwrapped form-control
|
|
4
|
+
* children (Input/Select used bare, as the filter-toolbar preset does) —
|
|
5
|
+
* without it, their own width:100% resolves as their flex-basis and the
|
|
6
|
+
* row stacks vertically instead of reading as a toolbar. Field-wrapped
|
|
7
|
+
* controls are unaffected: Field has no width of its own, so its flex-basis
|
|
8
|
+
* already comes from content. 200px is a standalone literal, matching
|
|
9
|
+
* Dialog's width={400|560|720} precedent — no shared scale reaches this
|
|
10
|
+
* range (sizeScale tops at 48, spacingScale at 144, both for heights/gaps
|
|
11
|
+
* not component widths).
|
|
12
|
+
*/
|
|
13
|
+
export declare const toolbarVars: Record<string, string>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Toolbar component tokens (--psi-toolbar-*) — D77.
|
|
2
|
+
*
|
|
3
|
+
* A deliberate default width for Toolbar's direct, unwrapped form-control
|
|
4
|
+
* children (Input/Select used bare, as the filter-toolbar preset does) —
|
|
5
|
+
* without it, their own width:100% resolves as their flex-basis and the
|
|
6
|
+
* row stacks vertically instead of reading as a toolbar. Field-wrapped
|
|
7
|
+
* controls are unaffected: Field has no width of its own, so its flex-basis
|
|
8
|
+
* already comes from content. 200px is a standalone literal, matching
|
|
9
|
+
* Dialog's width={400|560|720} precedent — no shared scale reaches this
|
|
10
|
+
* range (sizeScale tops at 48, spacingScale at 144, both for heights/gaps
|
|
11
|
+
* not component widths).
|
|
12
|
+
*/
|
|
13
|
+
export const toolbarVars = {
|
|
14
|
+
"control-width": "200px",
|
|
15
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Tooltip component tokens (--psi-tooltip-*). Inverted colors: dark bg, light fg. */
|
|
2
|
+
export const tooltipVars = {
|
|
3
|
+
bg: "var(--psi-bg-inverted)",
|
|
4
|
+
fg: "var(--psi-fg-on-inverted)",
|
|
5
|
+
// D56: tracks a sharper theme down, never rounder than radius-6.
|
|
6
|
+
radius: "min(var(--psi-control-radius), var(--psi-radius-6))",
|
|
7
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ResolvedTheme } from "./dsl/resolver.js";
|
|
2
|
+
export interface ContrastPair {
|
|
3
|
+
fg: string;
|
|
4
|
+
bg: string;
|
|
5
|
+
minRatio: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ContrastResult extends ContrastPair {
|
|
8
|
+
ratio: number;
|
|
9
|
+
pass: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Composite a foreground hex color with alpha onto an opaque background hex.
|
|
13
|
+
* Uses sRGB blending (gamma-encoded): mixed = fg * alpha + bg * (1 - alpha).
|
|
14
|
+
* Returns an opaque hex color matching browser rendering.
|
|
15
|
+
*/
|
|
16
|
+
export declare function compositeHex(fgHex: string, alpha: number, bgHex: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Check contrast ratios for a set of fg/bg pairs in a resolved theme.
|
|
19
|
+
*
|
|
20
|
+
* Alpha handling:
|
|
21
|
+
* - bg with alpha (e.g. fillTint*) is composited onto bgPrimary
|
|
22
|
+
* - fg with alpha (e.g. fgSecondary) is composited onto the bg token
|
|
23
|
+
*
|
|
24
|
+
* bgPrimary is used as the base surface for alpha-bg compositing.
|
|
25
|
+
*/
|
|
26
|
+
export declare function checkContrast(resolved: ResolvedTheme, pairs: ContrastPair[]): ContrastResult[];
|
|
27
|
+
/**
|
|
28
|
+
* Standard WCAG AA contrast pairs that every theme must pass.
|
|
29
|
+
*/
|
|
30
|
+
export declare const wcagAAPairs: ContrastPair[];
|
|
31
|
+
/** Solid component-variant labels on their variant backgrounds (spec: "every
|
|
32
|
+
* button/tag label on its variant background ≥ 4.5"). Tint-variant labels are
|
|
33
|
+
* already covered by the fg-on-tint pairs in wcagAAPairs. */
|
|
34
|
+
export declare const componentLabelPairs: ContrastPair[];
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { wcagContrast, formatHex, rgb } from "culori";
|
|
2
|
+
// ── Alpha compositing ─────────────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* Composite a foreground hex color with alpha onto an opaque background hex.
|
|
5
|
+
* Uses sRGB blending (gamma-encoded): mixed = fg * alpha + bg * (1 - alpha).
|
|
6
|
+
* Returns an opaque hex color matching browser rendering.
|
|
7
|
+
*/
|
|
8
|
+
export function compositeHex(fgHex, alpha, bgHex) {
|
|
9
|
+
const f = rgb(fgHex);
|
|
10
|
+
const b = rgb(bgHex);
|
|
11
|
+
if (!f)
|
|
12
|
+
throw new Error(`compositeHex: unparseable color "${fgHex}"`);
|
|
13
|
+
if (!b)
|
|
14
|
+
throw new Error(`compositeHex: unparseable color "${bgHex}"`);
|
|
15
|
+
const hex = formatHex({
|
|
16
|
+
mode: "rgb",
|
|
17
|
+
r: f.r * alpha + b.r * (1 - alpha),
|
|
18
|
+
g: f.g * alpha + b.g * (1 - alpha),
|
|
19
|
+
b: f.b * alpha + b.b * (1 - alpha),
|
|
20
|
+
});
|
|
21
|
+
if (!hex)
|
|
22
|
+
throw new Error(`compositeHex: could not format the blend of "${fgHex}" and "${bgHex}"`);
|
|
23
|
+
return hex;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Composite a foreground color with alpha onto an opaque background.
|
|
27
|
+
* Delegates to compositeHex for sRGB blending.
|
|
28
|
+
*/
|
|
29
|
+
function compositeOnBackground(fg, bg) {
|
|
30
|
+
const alpha = fg.oklch.alpha ?? 1;
|
|
31
|
+
if (alpha >= 1) {
|
|
32
|
+
return fg.hex;
|
|
33
|
+
}
|
|
34
|
+
return compositeHex(fg.hex, alpha, bg.hex);
|
|
35
|
+
}
|
|
36
|
+
// ── Contrast checker ──────────────────────────────────────────────
|
|
37
|
+
/**
|
|
38
|
+
* Check contrast ratios for a set of fg/bg pairs in a resolved theme.
|
|
39
|
+
*
|
|
40
|
+
* Alpha handling:
|
|
41
|
+
* - bg with alpha (e.g. fillTint*) is composited onto bgPrimary
|
|
42
|
+
* - fg with alpha (e.g. fgSecondary) is composited onto the bg token
|
|
43
|
+
*
|
|
44
|
+
* bgPrimary is used as the base surface for alpha-bg compositing.
|
|
45
|
+
*/
|
|
46
|
+
export function checkContrast(resolved, pairs) {
|
|
47
|
+
const bgPrimary = resolved.bgPrimary;
|
|
48
|
+
if (!bgPrimary) {
|
|
49
|
+
throw new Error("Theme must include bgPrimary for contrast checking");
|
|
50
|
+
}
|
|
51
|
+
return pairs.map((pair) => {
|
|
52
|
+
const fgToken = resolved[pair.fg];
|
|
53
|
+
const bgToken = resolved[pair.bg];
|
|
54
|
+
if (!fgToken) {
|
|
55
|
+
throw new Error(`Unknown foreground token: "${pair.fg}"`);
|
|
56
|
+
}
|
|
57
|
+
if (!bgToken) {
|
|
58
|
+
throw new Error(`Unknown background token: "${pair.bg}"`);
|
|
59
|
+
}
|
|
60
|
+
// Resolve effective bg: if bg has alpha, composite onto bgPrimary
|
|
61
|
+
const bgAlpha = bgToken.oklch.alpha ?? 1;
|
|
62
|
+
const effectiveBgHex = bgAlpha < 1
|
|
63
|
+
? compositeOnBackground(bgToken, bgPrimary)
|
|
64
|
+
: bgToken.hex;
|
|
65
|
+
// Resolve effective fg: if fg has alpha, composite onto the bg
|
|
66
|
+
const fgAlpha = fgToken.oklch.alpha ?? 1;
|
|
67
|
+
const effectiveFgHex = fgAlpha < 1
|
|
68
|
+
? compositeOnBackground(fgToken, bgToken)
|
|
69
|
+
: fgToken.hex;
|
|
70
|
+
const ratio = wcagContrast(effectiveFgHex, effectiveBgHex);
|
|
71
|
+
return {
|
|
72
|
+
...pair,
|
|
73
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
74
|
+
pass: ratio >= pair.minRatio,
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// ── Default contrast pairs ────────────────────────────────────────
|
|
79
|
+
const bgSurfaces = [
|
|
80
|
+
"bgPrimary",
|
|
81
|
+
"bgSecondary",
|
|
82
|
+
"fillNeutral1",
|
|
83
|
+
"fillNeutral2",
|
|
84
|
+
"fillNeutral3",
|
|
85
|
+
"fillNeutral4",
|
|
86
|
+
"fillNeutral5",
|
|
87
|
+
"fillNeutral6",
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* Standard WCAG AA contrast pairs that every theme must pass.
|
|
91
|
+
*/
|
|
92
|
+
export const wcagAAPairs = [
|
|
93
|
+
// fgPrimary on all surfaces
|
|
94
|
+
...bgSurfaces.map((bg) => ({ fg: "fgPrimary", bg, minRatio: 4.5 })),
|
|
95
|
+
// fgSecondary on all surfaces
|
|
96
|
+
...bgSurfaces.map((bg) => ({ fg: "fgSecondary", bg, minRatio: 4.5 })),
|
|
97
|
+
// Semantic fg on primary background
|
|
98
|
+
{ fg: "fgAccent", bg: "bgPrimary", minRatio: 4.5 },
|
|
99
|
+
{ fg: "fgSuccess", bg: "bgPrimary", minRatio: 4.5 },
|
|
100
|
+
{ fg: "fgWarning", bg: "bgPrimary", minRatio: 4.5 },
|
|
101
|
+
{ fg: "fgDanger", bg: "bgPrimary", minRatio: 4.5 },
|
|
102
|
+
// Semantic fg on their tint backgrounds
|
|
103
|
+
{ fg: "fgAccent", bg: "fillTintAccent", minRatio: 4.5 },
|
|
104
|
+
{ fg: "fgSuccess", bg: "fillTintSuccess", minRatio: 4.5 },
|
|
105
|
+
{ fg: "fgWarning", bg: "fillTintWarning", minRatio: 4.5 },
|
|
106
|
+
{ fg: "fgDanger", bg: "fillTintDanger", minRatio: 4.5 },
|
|
107
|
+
// Primary text on a selected table row's accent wash (D62). Distinct from
|
|
108
|
+
// the fgAccent/fillTintAccent pair above: a selected row keeps ordinary
|
|
109
|
+
// body text, it does not recolor it to the accent.
|
|
110
|
+
{ fg: "fgPrimary", bg: "fillTintAccent", minRatio: 4.5 },
|
|
111
|
+
// Toast's status icon on the elevated surface (D64/D65). The three pairs
|
|
112
|
+
// above gate these foregrounds on bgPrimary; --psi-surface-bg — what Toast,
|
|
113
|
+
// Menu and Dialog all alias — resolves to bgSecondary, which was ungated for
|
|
114
|
+
// the semantic foregrounds until Toast put an icon there. Held at 4.5 even
|
|
115
|
+
// though an icon is non-text content (WCAG 1.4.11 would allow 3.0): the
|
|
116
|
+
// colours clear 4.5 anyway, and the matrix's posture is a single floor.
|
|
117
|
+
{ fg: "fgSuccess", bg: "bgSecondary", minRatio: 4.5 },
|
|
118
|
+
{ fg: "fgWarning", bg: "bgSecondary", minRatio: 4.5 },
|
|
119
|
+
{ fg: "fgDanger", bg: "bgSecondary", minRatio: 4.5 },
|
|
120
|
+
];
|
|
121
|
+
/** Solid component-variant labels on their variant backgrounds (spec: "every
|
|
122
|
+
* button/tag label on its variant background ≥ 4.5"). Tint-variant labels are
|
|
123
|
+
* already covered by the fg-on-tint pairs in wcagAAPairs. */
|
|
124
|
+
export const componentLabelPairs = [
|
|
125
|
+
{ fg: "fgOnAccent", bg: "fillAccent", minRatio: 4.5 }, // Button/IconButton/Tag accent label, Checkbox check, outline hover (D37)
|
|
126
|
+
{ fg: "fgStaticWhite", bg: "fillDanger", minRatio: 4.5 }, // Button/IconButton/Tag danger
|
|
127
|
+
{ fg: "fgStaticWhite", bg: "fillSuccess", minRatio: 4.5 }, // Tag success
|
|
128
|
+
{ fg: "fgStaticBlack", bg: "fillWarning", minRatio: 4.5 }, // Tag warning (D22)
|
|
129
|
+
{ fg: "fgOnInverted", bg: "bgInverted", minRatio: 4.5 }, // Tooltip label (D46 inversion pair)
|
|
130
|
+
];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CapOp, DeltaOp, SetOp, SlotRef, TokenDef, TokenRef } from "./types.js";
|
|
2
|
+
export declare function set(value: number): SetOp;
|
|
3
|
+
export declare function delta(value: number): DeltaOp;
|
|
4
|
+
export declare function cap(value: number): CapOp;
|
|
5
|
+
export declare const slot: Record<string, SlotRef>;
|
|
6
|
+
export declare const ref: Record<string, TokenRef>;
|
|
7
|
+
export declare function token(def: TokenDef): TokenDef;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// ── Channel operation builders ──────────────────────────────────────
|
|
2
|
+
export function set(value) {
|
|
3
|
+
return { op: "set", value };
|
|
4
|
+
}
|
|
5
|
+
export function delta(value) {
|
|
6
|
+
return { op: "delta", value };
|
|
7
|
+
}
|
|
8
|
+
export function cap(value) {
|
|
9
|
+
return { op: "cap", value };
|
|
10
|
+
}
|
|
11
|
+
// ── Source proxies ──────────────────────────────────────────────────
|
|
12
|
+
export const slot = new Proxy({}, {
|
|
13
|
+
get(_target, prop) {
|
|
14
|
+
return { type: "slot", name: prop };
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
export const ref = new Proxy({}, {
|
|
18
|
+
get(_target, prop) {
|
|
19
|
+
return { type: "ref", name: prop };
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
// ── Token builder ───────────────────────────────────────────────────
|
|
23
|
+
export function token(def) {
|
|
24
|
+
return { ...def };
|
|
25
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { formatHex, clampChroma } from "culori";
|
|
2
|
+
// ── Channel operation application ──────────────────────────────────
|
|
3
|
+
function applyOp(base, op) {
|
|
4
|
+
if (!op)
|
|
5
|
+
return base;
|
|
6
|
+
switch (op.op) {
|
|
7
|
+
case "set":
|
|
8
|
+
return op.value;
|
|
9
|
+
case "delta":
|
|
10
|
+
return base + op.value;
|
|
11
|
+
case "cap":
|
|
12
|
+
return Math.min(base, op.value);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// ── Formula string generation ──────────────────────────────────────
|
|
16
|
+
function formatOp(channel, op) {
|
|
17
|
+
return `${channel} ${op.op}(${op.value})`;
|
|
18
|
+
}
|
|
19
|
+
function formatFormula(def) {
|
|
20
|
+
const source = def.from.type === "slot"
|
|
21
|
+
? `slot(${def.from.name})`
|
|
22
|
+
: `ref(${def.from.name})`;
|
|
23
|
+
const ops = [];
|
|
24
|
+
if (def.l)
|
|
25
|
+
ops.push(formatOp("L", def.l));
|
|
26
|
+
if (def.c)
|
|
27
|
+
ops.push(formatOp("C", def.c));
|
|
28
|
+
if (def.h)
|
|
29
|
+
ops.push(formatOp("H", def.h));
|
|
30
|
+
if (def.alpha !== undefined)
|
|
31
|
+
ops.push(`alpha(${def.alpha})`);
|
|
32
|
+
return ops.length > 0 ? `${source} → ${ops.join(", ")}` : source;
|
|
33
|
+
}
|
|
34
|
+
// ── Resolver ───────────────────────────────────────────────────────
|
|
35
|
+
export function resolve(theme, palette, slots) {
|
|
36
|
+
const cache = new Map();
|
|
37
|
+
function resolveToken(name) {
|
|
38
|
+
const cached = cache.get(name);
|
|
39
|
+
if (cached)
|
|
40
|
+
return cached;
|
|
41
|
+
const def = theme[name];
|
|
42
|
+
if (!def) {
|
|
43
|
+
throw new Error(`Unknown token: "${name}"`);
|
|
44
|
+
}
|
|
45
|
+
// Resolve base L/C/H from source
|
|
46
|
+
let baseL;
|
|
47
|
+
let baseC;
|
|
48
|
+
let baseH;
|
|
49
|
+
let baseAlpha;
|
|
50
|
+
if (def.from.type === "slot") {
|
|
51
|
+
const paletteName = slots[def.from.name];
|
|
52
|
+
if (!paletteName) {
|
|
53
|
+
throw new Error(`Unknown slot: "${def.from.name}"`);
|
|
54
|
+
}
|
|
55
|
+
const entry = palette[paletteName];
|
|
56
|
+
if (!entry) {
|
|
57
|
+
throw new Error(`Unknown palette entry: "${paletteName}" (from slot "${def.from.name}")`);
|
|
58
|
+
}
|
|
59
|
+
baseL = entry.l;
|
|
60
|
+
baseC = entry.c;
|
|
61
|
+
baseH = entry.h;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// ref — recursively resolve the referenced token
|
|
65
|
+
const refToken = resolveToken(def.from.name);
|
|
66
|
+
baseL = refToken.oklch.l;
|
|
67
|
+
baseC = refToken.oklch.c;
|
|
68
|
+
baseH = refToken.oklch.h;
|
|
69
|
+
baseAlpha = refToken.oklch.alpha;
|
|
70
|
+
}
|
|
71
|
+
// Apply channel operations
|
|
72
|
+
const l = applyOp(baseL, def.l);
|
|
73
|
+
const c = applyOp(baseC, def.c);
|
|
74
|
+
const h = applyOp(baseH, def.h);
|
|
75
|
+
const alpha = def.alpha ?? baseAlpha;
|
|
76
|
+
// Build OKLCH color for culori
|
|
77
|
+
const oklchColor = {
|
|
78
|
+
mode: "oklch",
|
|
79
|
+
l,
|
|
80
|
+
c,
|
|
81
|
+
h,
|
|
82
|
+
};
|
|
83
|
+
if (alpha !== undefined) {
|
|
84
|
+
oklchColor.alpha = alpha;
|
|
85
|
+
}
|
|
86
|
+
// Gamut-map to sRGB via chroma clamping
|
|
87
|
+
const clamped = clampChroma(oklchColor, "oklch", "rgb");
|
|
88
|
+
// Extract the gamut-mapped OKLCH values
|
|
89
|
+
const clampedL = clamped?.l ?? l;
|
|
90
|
+
const clampedC = clamped?.c ?? 0;
|
|
91
|
+
const clampedH = clamped?.h ?? h;
|
|
92
|
+
// Generate hex (always 6-digit sRGB)
|
|
93
|
+
const hex = formatHex(clamped ?? oklchColor) ?? "#000000";
|
|
94
|
+
const resolved = {
|
|
95
|
+
name,
|
|
96
|
+
oklch: {
|
|
97
|
+
l: clampedL,
|
|
98
|
+
c: clampedC,
|
|
99
|
+
h: clampedH,
|
|
100
|
+
...(alpha !== undefined ? { alpha } : {}),
|
|
101
|
+
},
|
|
102
|
+
hex,
|
|
103
|
+
formula: formatFormula(def),
|
|
104
|
+
...(def.scopes !== undefined ? { scopes: def.scopes } : {}),
|
|
105
|
+
};
|
|
106
|
+
cache.set(name, resolved);
|
|
107
|
+
return resolved;
|
|
108
|
+
}
|
|
109
|
+
const result = {};
|
|
110
|
+
for (const name of Object.keys(theme)) {
|
|
111
|
+
result[name] = resolveToken(name);
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface SetOp {
|
|
2
|
+
readonly op: "set";
|
|
3
|
+
readonly value: number;
|
|
4
|
+
}
|
|
5
|
+
export interface DeltaOp {
|
|
6
|
+
readonly op: "delta";
|
|
7
|
+
readonly value: number;
|
|
8
|
+
}
|
|
9
|
+
export interface CapOp {
|
|
10
|
+
readonly op: "cap";
|
|
11
|
+
readonly value: number;
|
|
12
|
+
}
|
|
13
|
+
export type ChannelOp = SetOp | DeltaOp | CapOp;
|
|
14
|
+
export interface SlotRef {
|
|
15
|
+
readonly type: "slot";
|
|
16
|
+
readonly name: string;
|
|
17
|
+
}
|
|
18
|
+
export interface TokenRef {
|
|
19
|
+
readonly type: "ref";
|
|
20
|
+
readonly name: string;
|
|
21
|
+
}
|
|
22
|
+
export type TokenSource = SlotRef | TokenRef;
|
|
23
|
+
export interface TokenDef {
|
|
24
|
+
readonly from: TokenSource;
|
|
25
|
+
readonly l?: ChannelOp;
|
|
26
|
+
readonly c?: ChannelOp;
|
|
27
|
+
readonly h?: ChannelOp;
|
|
28
|
+
readonly alpha?: number;
|
|
29
|
+
/** D46: property groups / CSS properties this token may bind to
|
|
30
|
+
* (see src/scopes.ts). Unscoped tokens are valid everywhere. */
|
|
31
|
+
readonly scopes?: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
export interface PaletteEntry {
|
|
34
|
+
readonly l: number;
|
|
35
|
+
readonly c: number;
|
|
36
|
+
readonly h: number;
|
|
37
|
+
}
|
|
38
|
+
export type Palette = Record<string, PaletteEntry>;
|
|
39
|
+
export interface SlotMap {
|
|
40
|
+
readonly ink: string;
|
|
41
|
+
readonly canvas: string;
|
|
42
|
+
readonly accent: string;
|
|
43
|
+
readonly success: string;
|
|
44
|
+
readonly warning: string;
|
|
45
|
+
readonly danger: string;
|
|
46
|
+
}
|
|
47
|
+
export type ThemeDef = Record<string, TokenDef>;
|
|
48
|
+
export interface ResolvedToken {
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly oklch: {
|
|
51
|
+
readonly l: number;
|
|
52
|
+
readonly c: number;
|
|
53
|
+
readonly h: number;
|
|
54
|
+
readonly alpha?: number;
|
|
55
|
+
};
|
|
56
|
+
readonly hex: string;
|
|
57
|
+
readonly formula: string;
|
|
58
|
+
readonly scopes?: readonly string[];
|
|
59
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SlotMap, ThemeDef } from "./types.js";
|
|
2
|
+
export declare class ValidationError extends Error {
|
|
3
|
+
readonly path: string[];
|
|
4
|
+
constructor(message: string, path?: string[]);
|
|
5
|
+
}
|
|
6
|
+
export declare function validate(theme: ThemeDef, slots: SlotMap, existingNames?: string[]): void;
|
|
7
|
+
/** D46: the same token name must carry identical scopes in every theme —
|
|
8
|
+
* scopes are a property of the token's meaning, not of a theme. */
|
|
9
|
+
export declare function validateScopeConsistency(themes: Record<string, ThemeDef>): void;
|
|
10
|
+
/** D46 follow-up (HAN-21): reject semantic token kebab names that a
|
|
11
|
+
* scale-family lookup would shadow before the semantic lookup runs. */
|
|
12
|
+
export declare function validateNoScalePrefixShadow(kebabNames: readonly string[]): void;
|