@kerfjs/ui 5.0.0-beta.52 → 5.0.0-beta.54

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.
Files changed (188) hide show
  1. package/README.md +1 -0
  2. package/ai/application-ui-diagnostic-ids-v1.json +1 -0
  3. package/ai/component-catalog-v2.d.ts +3 -0
  4. package/ai/component-catalog-v2.json +85 -7
  5. package/ai/component-catalog-v2.schema.json +9 -1
  6. package/ai/component-catalog.json +108 -11
  7. package/ai/public-api-signatures-v1.md +215 -25
  8. package/ai/skill.md +23 -17
  9. package/ai/webawesome-jsx-signatures-v1.md +1 -1
  10. package/analyzer/index.mjs +48 -0
  11. package/analyzer/loud-pairs.d.mts +49 -0
  12. package/analyzer/loud-pairs.mjs +357 -0
  13. package/dist/app-tab.d.ts +7 -5
  14. package/dist/app-tab.js +1 -1
  15. package/dist/browser/segmented-control.js +0 -1
  16. package/dist/catalog.js +4 -4
  17. package/dist/{chunk-EKYZCRCX.js → chunk-3CGQ7ZNO.js} +54 -22
  18. package/dist/chunk-3CGQ7ZNO.js.map +1 -0
  19. package/dist/{chunk-7XJO4D6A.js → chunk-3NBL3OUQ.js} +2 -2
  20. package/dist/{chunk-7XJO4D6A.js.map → chunk-3NBL3OUQ.js.map} +1 -1
  21. package/dist/{chunk-2S7OWAEV.js → chunk-44IG7PFV.js} +3 -3
  22. package/dist/{chunk-2S7OWAEV.js.map → chunk-44IG7PFV.js.map} +1 -1
  23. package/dist/{chunk-UKKSMZYT.js → chunk-46263QLK.js} +51 -1
  24. package/dist/chunk-46263QLK.js.map +1 -0
  25. package/dist/{chunk-DNCOFEUL.js → chunk-5I22LMCV.js} +2 -2
  26. package/dist/{chunk-DNCOFEUL.js.map → chunk-5I22LMCV.js.map} +1 -1
  27. package/dist/{chunk-NDUGJ4TU.js → chunk-5NDQABWK.js} +1 -1
  28. package/dist/{chunk-NDUGJ4TU.js.map → chunk-5NDQABWK.js.map} +1 -1
  29. package/dist/{chunk-KAQUUTZR.js → chunk-ATHXW6RP.js} +2 -2
  30. package/dist/{chunk-KAQUUTZR.js.map → chunk-ATHXW6RP.js.map} +1 -1
  31. package/dist/{chunk-JGFZ42ZF.js → chunk-BJJKFVWW.js} +14 -5
  32. package/dist/chunk-BJJKFVWW.js.map +1 -0
  33. package/dist/{chunk-T7C2VZUB.js → chunk-C2FAYFDE.js} +2 -2
  34. package/dist/{chunk-T7C2VZUB.js.map → chunk-C2FAYFDE.js.map} +1 -1
  35. package/dist/{chunk-SV22UIW4.js → chunk-DQA5FMOX.js} +2 -2
  36. package/dist/{chunk-SV22UIW4.js.map → chunk-DQA5FMOX.js.map} +1 -1
  37. package/dist/{chunk-LIOXPQ7N.js → chunk-GUIHMIT2.js} +12 -10
  38. package/dist/chunk-GUIHMIT2.js.map +1 -0
  39. package/dist/{chunk-3S7OUHUN.js → chunk-KPNTVB7A.js} +9 -4
  40. package/dist/chunk-KPNTVB7A.js.map +1 -0
  41. package/dist/{chunk-IR53BQUJ.js → chunk-NE5JAOQS.js} +9 -9
  42. package/dist/chunk-NE5JAOQS.js.map +1 -0
  43. package/dist/chunk-PSHUSWQH.js +18 -0
  44. package/dist/chunk-PSHUSWQH.js.map +1 -0
  45. package/dist/{chunk-OSCL5TKR.js → chunk-RNZAKSME.js} +11 -8
  46. package/dist/chunk-RNZAKSME.js.map +1 -0
  47. package/dist/{chunk-ABAXNUXU.js → chunk-T22EM4ET.js} +7 -5
  48. package/dist/chunk-T22EM4ET.js.map +1 -0
  49. package/dist/{chunk-YZV2PTY4.js → chunk-TZ5IHWB2.js} +3 -3
  50. package/dist/{chunk-YZV2PTY4.js.map → chunk-TZ5IHWB2.js.map} +1 -1
  51. package/dist/{chunk-VOCPXGQN.js → chunk-VHADGP3B.js} +9 -8
  52. package/dist/chunk-VHADGP3B.js.map +1 -0
  53. package/dist/{chunk-2CE22Q2K.js → chunk-WZ2GLEY4.js} +2 -1
  54. package/dist/chunk-WZ2GLEY4.js.map +1 -0
  55. package/dist/{chunk-M6GLI6A6.js → chunk-X5QSICJ3.js} +5 -5
  56. package/dist/chunk-X5QSICJ3.js.map +1 -0
  57. package/dist/chunk-XQV2B4IL.js +14 -0
  58. package/dist/chunk-XQV2B4IL.js.map +1 -0
  59. package/dist/{chunk-4XE2CDYB.js → chunk-ZMBU522F.js} +2 -2
  60. package/dist/{chunk-4XE2CDYB.js.map → chunk-ZMBU522F.js.map} +1 -1
  61. package/dist/collapsible-panel.d.ts +6 -2
  62. package/dist/collapsible-panel.js +1 -0
  63. package/dist/collapsible-panel.js.map +1 -1
  64. package/dist/css-values.d.ts +1 -1
  65. package/dist/css-values.js +1 -1
  66. package/dist/floating-toolbar.d.ts +5 -2
  67. package/dist/floating-toolbar.js +1 -1
  68. package/dist/grid.js +1 -1
  69. package/dist/index.js +20 -19
  70. package/dist/list-action-row.d.ts +2 -1
  71. package/dist/list-action-row.js +1 -1
  72. package/dist/list-header.d.ts +1 -0
  73. package/dist/list-header.js +1 -1
  74. package/dist/list-item.d.ts +5 -3
  75. package/dist/list-item.js +1 -1
  76. package/dist/list.js +1 -1
  77. package/dist/nav-stack.js +1 -1
  78. package/dist/resizable-region.d.ts +4 -1
  79. package/dist/resizable-region.js +1 -1
  80. package/dist/row.js +1 -1
  81. package/dist/segmented-control.d.ts +1 -1
  82. package/dist/segmented-control.js +1 -3
  83. package/dist/select.js +1 -1
  84. package/dist/skeleton.js +1 -1
  85. package/dist/spacer.js +1 -1
  86. package/dist/split-view.js +2 -2
  87. package/dist/state-banner.d.ts +1 -1
  88. package/dist/state-banner.js +1 -1
  89. package/dist/styles/badge.css +17 -6
  90. package/dist/styles/collapsible-panel.css +89 -11
  91. package/dist/styles/floating-toolbar.css +11 -6
  92. package/dist/styles/foundation.css +82 -26
  93. package/dist/styles/list-action-row.css +29 -8
  94. package/dist/styles/list-header.css +19 -7
  95. package/dist/styles/list-item.css +11 -3
  96. package/dist/styles/resizable-region.css +82 -14
  97. package/dist/styles/segmented-control.css +21 -1
  98. package/dist/styles/select.css +24 -13
  99. package/dist/styles/skeleton.css +33 -2
  100. package/dist/styles/state-banner.css +48 -12
  101. package/dist/styles/webawesome.css +47 -20
  102. package/dist/styles/workbench.css +473 -11
  103. package/dist/toolbar-text.js +1 -1
  104. package/dist/value-table.js +1 -1
  105. package/dist/wire-resizable-regions.d.ts +2 -1
  106. package/dist/wire-resizable-regions.js +2 -1
  107. package/dist/wire-tab-bars.js +1 -1
  108. package/dist/wire-workbench.d.ts +109 -0
  109. package/dist/wire-workbench.js +355 -0
  110. package/dist/wire-workbench.js.map +1 -0
  111. package/dist/workbench.d.ts +65 -6
  112. package/dist/workbench.js +180 -76
  113. package/dist/workbench.js.map +1 -1
  114. package/docs/accessibility.md +54 -9
  115. package/docs/app-layouts.md +12 -1
  116. package/docs/collapsible-panel.md +59 -1
  117. package/docs/component-contract.md +76 -4
  118. package/docs/component-selection.md +16 -6
  119. package/docs/design/templates/empty-state/with-action-dark.svg +1 -1
  120. package/docs/design/templates/empty-state-dark.svg +1 -1
  121. package/docs/design/templates/list/stack-dark.svg +1 -1
  122. package/docs/design/templates/list/stack.svg +1 -1
  123. package/docs/design/templates/list-action-row/selected.svg +1 -1
  124. package/docs/design/templates/list-action-row.svg +1 -1
  125. package/docs/design/templates/list-dark.svg +1 -1
  126. package/docs/design/templates/list-header/action-dark.svg +1 -1
  127. package/docs/design/templates/list-header/action.svg +1 -1
  128. package/docs/design/templates/list-header/count-dark.svg +1 -1
  129. package/docs/design/templates/list-header/count.svg +1 -1
  130. package/docs/design/templates/list-header/inline-action-dark.svg +1 -1
  131. package/docs/design/templates/list-header/inline-action.svg +1 -1
  132. package/docs/design/templates/list-header/toggle-expanded-dark.svg +1 -1
  133. package/docs/design/templates/list-header/toggle-expanded.svg +1 -1
  134. package/docs/design/templates/list-header-dark.svg +1 -1
  135. package/docs/design/templates/list-header.svg +1 -1
  136. package/docs/design/templates/list-item/selected.svg +1 -1
  137. package/docs/design/templates/list-item.svg +1 -1
  138. package/docs/design/templates/list.svg +1 -1
  139. package/docs/design/templates/segmented-control/equal-dark.svg +1 -1
  140. package/docs/design/templates/segmented-control/equal.svg +1 -1
  141. package/docs/design/templates/segmented-control/pill-small-dark.svg +1 -1
  142. package/docs/design/templates/segmented-control/pill-small.svg +1 -1
  143. package/docs/design/templates/segmented-control-dark.svg +1 -1
  144. package/docs/design/templates/segmented-control.svg +1 -1
  145. package/docs/design/templates/state-banner/danger-dark.svg +1 -1
  146. package/docs/design/templates/state-banner/danger.svg +1 -1
  147. package/docs/design/templates/state-banner/info-badge-dark.svg +1 -1
  148. package/docs/design/templates/state-banner/info-badge.svg +1 -1
  149. package/docs/design/templates/state-banner/info-dark.svg +1 -1
  150. package/docs/design/templates/state-banner/info.svg +1 -1
  151. package/docs/design/templates/state-banner/success-dark.svg +1 -1
  152. package/docs/design/templates/state-banner/success.svg +1 -1
  153. package/docs/design/templates/state-banner/warning-dark.svg +1 -1
  154. package/docs/design/templates/state-banner/warning.svg +1 -1
  155. package/docs/design/templates/state-banner-dark.svg +1 -1
  156. package/docs/design/templates/state-banner.svg +1 -1
  157. package/docs/design/templates/tab-bar/workspace-tabs-dark.svg +1 -1
  158. package/docs/design/templates/tab-bar/workspace-tabs.svg +1 -1
  159. package/docs/design/templates/tab-bar-dark.svg +1 -1
  160. package/docs/design/templates/tab-bar.svg +1 -1
  161. package/docs/design/templates/token-search-field/tokens.svg +1 -1
  162. package/docs/design/templates/token-search-field.svg +1 -1
  163. package/docs/design/templates.md +11 -1
  164. package/docs/device-class.md +2 -1
  165. package/docs/document-baseline.md +4 -0
  166. package/docs/layout.md +5 -1
  167. package/docs/recipes.md +5 -3
  168. package/docs/type-contracts.md +2 -1
  169. package/docs/ui-analyzer.md +94 -19
  170. package/docs/ui-doctor.md +6 -0
  171. package/docs/ux-demo.md +2 -2
  172. package/docs/webawesome-theme.md +18 -2
  173. package/docs/workbench.md +302 -7
  174. package/llms.txt +14 -6
  175. package/package.json +5 -1
  176. package/ux-demo/recipes/collapsible-sidebar.tsx +8 -7
  177. package/ux-demo/recipes/loading-inspector.tsx +3 -2
  178. package/dist/chunk-2CE22Q2K.js.map +0 -1
  179. package/dist/chunk-3S7OUHUN.js.map +0 -1
  180. package/dist/chunk-ABAXNUXU.js.map +0 -1
  181. package/dist/chunk-EKYZCRCX.js.map +0 -1
  182. package/dist/chunk-IR53BQUJ.js.map +0 -1
  183. package/dist/chunk-JGFZ42ZF.js.map +0 -1
  184. package/dist/chunk-LIOXPQ7N.js.map +0 -1
  185. package/dist/chunk-M6GLI6A6.js.map +0 -1
  186. package/dist/chunk-OSCL5TKR.js.map +0 -1
  187. package/dist/chunk-UKKSMZYT.js.map +0 -1
  188. package/dist/chunk-VOCPXGQN.js.map +0 -1
@@ -14,6 +14,7 @@ import ts from 'typescript';
14
14
 
15
15
  import { loadApplicationUiProfile } from '../ai/application-ui-profile.mjs';
16
16
  import { isUiTraversalExcluded } from '../traversal-exclusions.mjs';
17
+ import { collectLoudDeclarations, inspectLoudPairs } from './loud-pairs.mjs';
17
18
 
18
19
  export const UI_ANALYSIS_SCHEMA_VERSION = 1;
19
20
 
@@ -41,6 +42,10 @@ export const UI_ANALYSIS_RULES = Object.freeze({
41
42
  'KUI-L015': { severity: 'error', title: 'Non-standalone CSS expression' },
42
43
  'KUI-L016': { severity: 'error', title: 'Forbidden declaration-list escape' },
43
44
  'KUI-L017': { severity: 'review', title: 'Exceptional spacing shorthand' },
45
+ 'KUI-L018': {
46
+ severity: 'review',
47
+ title: 'Loud fill override without its on-loud pair',
48
+ },
44
49
  });
45
50
 
46
51
  const adoptionRules = new Set([
@@ -344,6 +349,7 @@ async function inspectCss(
344
349
  diagnostics,
345
350
  cssFacts,
346
351
  adoption = false,
352
+ siblingOnLoud = [],
347
353
  ) {
348
354
  let root;
349
355
  try {
@@ -358,6 +364,15 @@ async function inspectCss(
358
364
  );
359
365
  return;
360
366
  }
367
+ inspectLoudPairs(
368
+ file,
369
+ root,
370
+ (decl, message, evidence) =>
371
+ diagnostics.push(
372
+ diagnostic('KUI-L018', location(file, decl), message, evidence),
373
+ ),
374
+ siblingOnLoud,
375
+ );
361
376
  root.walkRules((rule) => {
362
377
  const classes = [
363
378
  ...rule.selector.matchAll(/\.([_a-zA-Z]+[_a-zA-Z0-9-]*)/g),
@@ -962,6 +977,38 @@ export async function analyzeUiProject({
962
977
  const styleConsumers = new Map(
963
978
  [...styleFacts.keys()].map((file) => [file, new Set()]),
964
979
  );
980
+ // KUI-L018 accepts an on-loud from another stylesheet the same entry loads:
981
+ // every stylesheet reachable from one file (a source module's CSS imports,
982
+ // or a stylesheet with its @imports) is loaded together.
983
+ const onLoudByStyle = new Map();
984
+ for (const style of styleFacts.keys()) {
985
+ let parsed;
986
+ try {
987
+ parsed = postcss.parse(contents.get(style), { from: style });
988
+ } catch {
989
+ continue;
990
+ }
991
+ onLoudByStyle.set(
992
+ style,
993
+ collectLoudDeclarations(style, parsed).filter(
994
+ ({ kind }) => kind === 'on-loud',
995
+ ),
996
+ );
997
+ }
998
+ const coLoadedStyles = new Map(
999
+ [...styleFacts.keys()].map((style) => [style, new Set()]),
1000
+ );
1001
+ for (const file of files) {
1002
+ const loaded = new Set(reachableStyleFiles(file, imports, styleFacts));
1003
+ if (styleFacts.has(file)) loaded.add(file);
1004
+ for (const style of loaded)
1005
+ for (const sibling of loaded)
1006
+ if (sibling !== style) coLoadedStyles.get(style).add(sibling);
1007
+ }
1008
+ const siblingOnLoud = (style) =>
1009
+ [...coLoadedStyles.get(style)]
1010
+ .sort()
1011
+ .flatMap((sibling) => onLoudByStyle.get(sibling) ?? []);
965
1012
  for (const file of files.filter((item) =>
966
1013
  sourceExtensions.has(extname(item)),
967
1014
  )) {
@@ -991,6 +1038,7 @@ export async function analyzeUiProject({
991
1038
  fileDiagnostics,
992
1039
  new Map(),
993
1040
  adoption,
1041
+ siblingOnLoud(file),
994
1042
  );
995
1043
  recordDiagnostics(fileDiagnostics, context);
996
1044
  }
@@ -0,0 +1,49 @@
1
+ import type { Declaration, Root } from 'postcss';
2
+
3
+ export type Rgb = [number, number, number];
4
+
5
+ export interface LoudScope {
6
+ key: string;
7
+ atScope: string;
8
+ bare: string;
9
+ selector: string;
10
+ }
11
+
12
+ export interface LoudDeclaration {
13
+ decl: Declaration;
14
+ file: string;
15
+ tone: string;
16
+ kind: 'fill-loud' | 'on-loud';
17
+ value: string;
18
+ scope: LoudScope;
19
+ }
20
+
21
+ export declare const LOUD_PAIR_MIN_CONTRAST: number;
22
+ export declare const DEFAULT_ON_LOUD: Readonly<Record<string, string>>;
23
+
24
+ export declare function literalSchemeColors(
25
+ value: string,
26
+ ): { light: Rgb; dark: Rgb } | undefined;
27
+
28
+ export declare function contrastRatio(first: Rgb, second: Rgb): number;
29
+
30
+ export declare function loudPairContrast(
31
+ fillValue: string,
32
+ onLoudValue: string,
33
+ ): { scheme: 'light' | 'dark'; ratio: number } | undefined;
34
+
35
+ export declare function collectLoudDeclarations(
36
+ file: string,
37
+ root: Root,
38
+ ): LoudDeclaration[];
39
+
40
+ export declare function inspectLoudPairs(
41
+ file: string,
42
+ root: Root,
43
+ report: (
44
+ decl: Declaration,
45
+ message: string,
46
+ evidence: Record<string, unknown>,
47
+ ) => void,
48
+ siblingOnLoud?: readonly LoudDeclaration[],
49
+ ): void;
@@ -0,0 +1,357 @@
1
+ import postcss from 'postcss';
2
+
3
+ // KUI-L018: the shipped Web Awesome theme pairs every loud fill with an
4
+ // on-loud foreground that clears WCAG AA; an override that moves one without
5
+ // the other silently drops that guarantee (see docs/webawesome-theme.md).
6
+ const loudToken =
7
+ /^--wa-color-(neutral|brand|success|warning|danger|pop)-(fill-loud|on-loud)$/;
8
+
9
+ /** WCAG AA contrast for normal text. */
10
+ export const LOUD_PAIR_MIN_CONTRAST = 4.5;
11
+
12
+ /**
13
+ * The on-loud foreground `@kerfjs/ui/webawesome.css` ships for each tone,
14
+ * where it is a literal color. An unpaired fill that still clears AA against
15
+ * it keeps the guarantee. `neutral` follows the surface token and `pop` has no
16
+ * Web Awesome default, so neither can be proven and an unpaired fill stays a
17
+ * finding. A unit test keeps this table equal to the shipped stylesheet.
18
+ */
19
+ export const DEFAULT_ON_LOUD = Object.freeze({
20
+ brand: 'light-dark(#fff, #111113)',
21
+ success: '#1d1d1f',
22
+ warning: '#1d1d1f',
23
+ danger: 'light-dark(#fff, #111113)',
24
+ });
25
+
26
+ const NAMED_COLORS = new Map([
27
+ ['white', [255, 255, 255]],
28
+ ['black', [0, 0, 0]],
29
+ ]);
30
+
31
+ function channel(value, scale) {
32
+ const text = value.trim();
33
+ if (text.endsWith('%')) return (Number(text.slice(0, -1)) / 100) * scale;
34
+ return Number(text);
35
+ }
36
+
37
+ function opaque(alpha) {
38
+ if (alpha === undefined) return true;
39
+ const text = alpha.trim();
40
+ const value = text.endsWith('%')
41
+ ? Number(text.slice(0, -1)) / 100
42
+ : Number(text);
43
+ return value === 1;
44
+ }
45
+
46
+ function hslToRgb(hue, saturation, lightness) {
47
+ const h = (((hue % 360) + 360) % 360) / 360;
48
+ const s = saturation / 100;
49
+ const l = lightness / 100;
50
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
51
+ const p = 2 * l - q;
52
+ const convert = (t) => {
53
+ const x = t < 0 ? t + 1 : t > 1 ? t - 1 : t;
54
+ if (x < 1 / 6) return p + (q - p) * 6 * x;
55
+ if (x < 1 / 2) return q;
56
+ if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6;
57
+ return p;
58
+ };
59
+ return [convert(h + 1 / 3), convert(h), convert(h - 1 / 3)].map(
60
+ (component) => component * 255,
61
+ );
62
+ }
63
+
64
+ /** An opaque literal sRGB color as `[r, g, b]` (0-255), else `undefined`. */
65
+ function parseOpaqueColor(value) {
66
+ const text = value.trim().toLowerCase();
67
+ if (NAMED_COLORS.has(text)) return NAMED_COLORS.get(text);
68
+ const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text);
69
+ if (hex) {
70
+ const digits =
71
+ hex[1].length <= 4
72
+ ? [...hex[1]].map((digit) => digit + digit).join('')
73
+ : hex[1];
74
+ if (digits.length === 8 && digits.slice(6) !== 'ff') return undefined;
75
+ return [0, 2, 4].map((start) =>
76
+ Number.parseInt(digits.slice(start, start + 2), 16),
77
+ );
78
+ }
79
+ const functional = /^(rgba?|hsla?)\(([^()]*)\)$/.exec(text);
80
+ if (!functional) return undefined;
81
+ const [components, alpha] = functional[2].includes('/')
82
+ ? functional[2].split('/')
83
+ : [functional[2]];
84
+ const parts = components.split(/[\s,]+/).filter(Boolean);
85
+ const alphaPart = alpha ?? parts[3];
86
+ if (parts.length < 3 || !opaque(alphaPart)) return undefined;
87
+ const rgb = functional[1].startsWith('rgb')
88
+ ? parts.slice(0, 3).map((part) => channel(part, 255))
89
+ : hslToRgb(
90
+ Number(parts[0].replace(/deg$/, '')),
91
+ Number(parts[1].replace(/%$/, '')),
92
+ Number(parts[2].replace(/%$/, '')),
93
+ );
94
+ return rgb.every((component) => Number.isFinite(component))
95
+ ? rgb.map((component) => Math.min(255, Math.max(0, component)))
96
+ : undefined;
97
+ }
98
+
99
+ /**
100
+ * A literal color value resolved per color scheme: `{ light, dark }` of
101
+ * `[r, g, b]`. `light-dark(a, b)` splits by scheme; a plain literal applies to
102
+ * both. Anything else (a `var()`, `color-mix()`, a keyword, transparency) is
103
+ * not provable and returns `undefined`.
104
+ */
105
+ export function literalSchemeColors(value) {
106
+ const text = value
107
+ .replace(/!important\s*$/i, '')
108
+ .trim()
109
+ .toLowerCase();
110
+ const lightDark = /^light-dark\((.*)\)$/.exec(text);
111
+ if (lightDark) {
112
+ const parts = postcss.list.comma(lightDark[1]);
113
+ if (parts.length !== 2) return undefined;
114
+ const [light, dark] = parts.map(parseOpaqueColor);
115
+ return light && dark ? { light, dark } : undefined;
116
+ }
117
+ const color = parseOpaqueColor(text);
118
+ return color ? { light: color, dark: color } : undefined;
119
+ }
120
+
121
+ function luminance([red, green, blue]) {
122
+ const linear = (component) => {
123
+ const value = component / 255;
124
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
125
+ };
126
+ return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue);
127
+ }
128
+
129
+ /** WCAG 2 contrast ratio between two `[r, g, b]` colors. */
130
+ export function contrastRatio(first, second) {
131
+ const [lighter, darker] = [luminance(first), luminance(second)].sort(
132
+ (a, b) => b - a,
133
+ );
134
+ return (lighter + 0.05) / (darker + 0.05);
135
+ }
136
+
137
+ /**
138
+ * The lowest per-scheme contrast of a fill/foreground pair, or `undefined`
139
+ * when either value is not a provable literal color.
140
+ */
141
+ export function loudPairContrast(fillValue, onLoudValue) {
142
+ const fill = literalSchemeColors(fillValue);
143
+ const foreground = literalSchemeColors(onLoudValue);
144
+ if (!fill || !foreground) return undefined;
145
+ const results = ['light', 'dark'].map((scheme) => ({
146
+ scheme,
147
+ ratio: contrastRatio(fill[scheme], foreground[scheme]),
148
+ }));
149
+ return results.reduce((lowest, item) =>
150
+ item.ratio < lowest.ratio ? item : lowest,
151
+ );
152
+ }
153
+
154
+ function normalizeSelector(selector) {
155
+ return selector
156
+ .replace(/\s+/g, ' ')
157
+ .replace(/\s*([>+~])\s*/g, '$1')
158
+ .trim();
159
+ }
160
+
161
+ function resolvedSelectors(node) {
162
+ const chain = [];
163
+ let current = node;
164
+ while (current && current.type !== 'root') {
165
+ chain.unshift(current);
166
+ current = current.parent;
167
+ }
168
+ let selectors = [''];
169
+ const atRules = [];
170
+ for (const item of chain) {
171
+ if (item.type === 'atrule')
172
+ atRules.push(`@${item.name} ${item.params.replace(/\s+/g, ' ').trim()}`);
173
+ if (item.type !== 'rule') continue;
174
+ const own = postcss.list.comma(item.selector).map(normalizeSelector);
175
+ selectors = selectors.flatMap((parent) =>
176
+ own.map((child) => {
177
+ if (child.includes('&'))
178
+ return child.replaceAll('&', parent || ':root');
179
+ return parent ? `${parent} ${child}` : child;
180
+ }),
181
+ );
182
+ }
183
+ const atScope = atRules.join(' ');
184
+ return selectors.map((selector) => ({
185
+ key: `${atScope}|${selector}`,
186
+ atScope,
187
+ bare: selector,
188
+ selector: atScope ? `${atScope} ${selector}`.trim() : selector,
189
+ }));
190
+ }
191
+
192
+ /**
193
+ * Every loud-token declaration in a stylesheet, one record per resolved
194
+ * selector scope: `{ decl, file, tone, kind, value, scope }`.
195
+ */
196
+ export function collectLoudDeclarations(file, root) {
197
+ const records = [];
198
+ root.walkDecls((decl) => {
199
+ const match = loudToken.exec(decl.prop.trim());
200
+ if (!match) return;
201
+ for (const scope of resolvedSelectors(decl.parent))
202
+ records.push({
203
+ decl,
204
+ file,
205
+ tone: match[1],
206
+ kind: match[2],
207
+ value: decl.value,
208
+ scope,
209
+ });
210
+ });
211
+ return records;
212
+ }
213
+
214
+ // Selectors that match the document root (or a shadow root's host), so a
215
+ // custom property set there inherits into every other scope.
216
+ const rootSelectors = new Set(['', ':root', 'html', ':host', '*']);
217
+
218
+ // Whether an on-loud declared in `outer` provably reaches an element that
219
+ // `inner` matches: the same scope, or an ancestor one. The outer at-rule chain
220
+ // must be absent or a prefix of the inner one (an unconditional value applies
221
+ // inside `@media`, not the reverse), and the outer selector must be a document
222
+ // root selector or the leading compound selector of a descendant/child chain.
223
+ function reaches(outer, inner) {
224
+ if (
225
+ outer.atScope &&
226
+ outer.atScope !== inner.atScope &&
227
+ !inner.atScope.startsWith(`${outer.atScope} `)
228
+ )
229
+ return false;
230
+ if (outer.bare === inner.bare || rootSelectors.has(outer.bare)) return true;
231
+ return (
232
+ inner.bare.startsWith(outer.bare) &&
233
+ [' ', '>'].includes(inner.bare[outer.bare.length])
234
+ );
235
+ }
236
+
237
+ // The on-loud that governs a fill scope: the same scope wins (this stylesheet
238
+ // before a co-loaded one), then the nearest ancestor (the longest selector).
239
+ function governingOnLoud(fill, onLoud) {
240
+ const candidates = onLoud.filter(
241
+ (record) => record.tone === fill.tone && reaches(record.scope, fill.scope),
242
+ );
243
+ const rank = (record) => [
244
+ record.scope.key === fill.scope.key ? 1 : 0,
245
+ record.file === fill.file ? 1 : 0,
246
+ record.scope.atScope.length + record.scope.bare.length,
247
+ ];
248
+ return candidates.sort((a, b) => {
249
+ const [left, right] = [rank(a), rank(b)];
250
+ for (let index = 0; index < left.length; index += 1)
251
+ if (left[index] !== right[index]) return right[index] - left[index];
252
+ return 0;
253
+ })[0];
254
+ }
255
+
256
+ const scopeLabel = (selector) => selector || '(top level)';
257
+ // Truncated rather than rounded, so a pair just under 4.5:1 never reads as
258
+ // passing.
259
+ const truncatedRatio = (ratio) => Math.floor(ratio * 100) / 100;
260
+ const ratioLabel = (ratio) => `${truncatedRatio(ratio).toFixed(2)}:1`;
261
+
262
+ /**
263
+ * Report each loud fill override whose tone lacks a governing on-loud (unless
264
+ * the fill provably clears AA against the theme's default foreground), and
265
+ * each literal pair that is present but below 4.5:1. `siblingOnLoud` holds
266
+ * on-loud records from the other stylesheets the same entry loads.
267
+ */
268
+ export function inspectLoudPairs(file, root, report, siblingOnLoud = []) {
269
+ const records = collectLoudDeclarations(file, root);
270
+ const onLoud = [
271
+ ...records.filter(({ kind }) => kind === 'on-loud'),
272
+ ...siblingOnLoud,
273
+ ];
274
+ const fills = new Map();
275
+ for (const record of records.filter(({ kind }) => kind === 'fill-loud')) {
276
+ const group = fills.get(record.decl) ?? [];
277
+ group.push(record);
278
+ fills.set(record.decl, group);
279
+ }
280
+ for (const [decl, scopes] of fills) {
281
+ const { tone } = scopes[0];
282
+ const pair = `--wa-color-${tone}-on-loud`;
283
+ const missing = [];
284
+ const failing = [];
285
+ for (const fill of scopes) {
286
+ const governing = governingOnLoud(fill, onLoud);
287
+ if (governing) {
288
+ const contrast = loudPairContrast(fill.value, governing.value);
289
+ if (contrast && contrast.ratio < LOUD_PAIR_MIN_CONTRAST)
290
+ failing.push({ fill, governing, contrast });
291
+ continue;
292
+ }
293
+ const fallback = DEFAULT_ON_LOUD[tone]
294
+ ? loudPairContrast(fill.value, DEFAULT_ON_LOUD[tone])
295
+ : undefined;
296
+ if (fallback && fallback.ratio >= LOUD_PAIR_MIN_CONTRAST) continue;
297
+ missing.push({ fill, fallback });
298
+ }
299
+ if (missing.length) {
300
+ const [measured] = missing
301
+ .map(({ fallback }) => fallback)
302
+ .filter(Boolean)
303
+ .sort((a, b) => a.ratio - b.ratio);
304
+ report(
305
+ decl,
306
+ `${decl.prop} is overridden without ${pair} in the same or an ancestor scope (${missing
307
+ .map(({ fill }) => scopeLabel(fill.scope.selector))
308
+ .join(', ')})${
309
+ measured
310
+ ? `, and it measures ${ratioLabel(measured.ratio)} against the theme's default ${pair} in the ${measured.scheme} scheme`
311
+ : ''
312
+ }; set ${pair} alongside it so the loud fill keeps an AA-clearing foreground.`,
313
+ {
314
+ property: decl.prop,
315
+ value: decl.value,
316
+ tone,
317
+ pairProperty: pair,
318
+ selectors: missing.map(({ fill }) => fill.scope.selector),
319
+ ...(measured
320
+ ? {
321
+ defaultPairValue: DEFAULT_ON_LOUD[tone],
322
+ contrast: truncatedRatio(measured.ratio),
323
+ scheme: measured.scheme,
324
+ }
325
+ : {}),
326
+ },
327
+ );
328
+ }
329
+ if (failing.length) {
330
+ const worst = failing.reduce((lowest, item) =>
331
+ item.contrast.ratio < lowest.contrast.ratio ? item : lowest,
332
+ );
333
+ report(
334
+ decl,
335
+ `${decl.prop} (${decl.value.trim()}) and its ${pair} (${worst.governing.value.trim()}) measure ${ratioLabel(worst.contrast.ratio)} in the ${worst.contrast.scheme} scheme, below the ${LOUD_PAIR_MIN_CONTRAST}:1 AA minimum (${failing
336
+ .map(({ fill }) => scopeLabel(fill.scope.selector))
337
+ .join(
338
+ ', ',
339
+ )}); choose a fill or foreground that clears 4.5:1 in every scheme.`,
340
+ {
341
+ property: decl.prop,
342
+ value: decl.value,
343
+ tone,
344
+ pairProperty: pair,
345
+ pairValue: worst.governing.value,
346
+ pairLocation: {
347
+ file: worst.governing.file,
348
+ line: worst.governing.decl.source?.start?.line ?? 1,
349
+ },
350
+ contrast: truncatedRatio(worst.contrast.ratio),
351
+ scheme: worst.contrast.scheme,
352
+ selectors: failing.map(({ fill }) => fill.scope.selector),
353
+ },
354
+ );
355
+ }
356
+ }
357
+ }
package/dist/app-tab.d.ts CHANGED
@@ -33,11 +33,13 @@ interface AppTabProps {
33
33
  /** Render as an unanimated loading skeleton, disabling select/close and dragging. */
34
34
  placeholder?: boolean;
35
35
  /**
36
- * Dormant but named: the tab is known and still opening. Keeps `name`
37
- * visible in the quiet text color and as the tab's accessible name, shows
38
- * `trailing` (for example a `LoadingSpinner`), and disables select, close,
39
- * and dragging with `aria-busy`, like `placeholder`. Same pill geometry as
40
- * the live tab, so swapping it in place does not shift the bar.
36
+ * Named and still opening: the tab is known but its content is loading.
37
+ * Keeps `name` visible in the quiet text color and as the tab's accessible
38
+ * name, shows `trailing` (for example a `LoadingSpinner`), and marks the
39
+ * tab `aria-busy`. Unlike `placeholder` it stays selectable — the
40
+ * application shows placeholder content in its panel until loading
41
+ * completes — while close and dragging stay disabled. Same pill geometry
42
+ * as the live tab, so swapping it in place does not shift the bar.
41
43
  * `placeholder` wins when both are set.
42
44
  */
43
45
  pending?: boolean;
package/dist/app-tab.js CHANGED
@@ -1,4 +1,4 @@
1
- export { AppTab } from './chunk-IR53BQUJ.js';
1
+ export { AppTab } from './chunk-NE5JAOQS.js';
2
2
 
3
3
 
4
4
 
@@ -1,4 +1,3 @@
1
1
  import '../styles/foundation.css';
2
- import '../styles/skeleton.css';
3
2
  import '../styles/segmented-control.css';
4
3
  export * from '../segmented-control.js';
package/dist/catalog.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { ToolbarControlGroup, ToolbarActionLink } from './chunk-ZIYOJFZD.js';
2
2
  import { Toolbar } from './chunk-MTM43SJO.js';
3
- import { ListItem } from './chunk-LIOXPQ7N.js';
4
- import { List } from './chunk-7XJO4D6A.js';
5
- import { ToolbarText } from './chunk-YZV2PTY4.js';
3
+ import { ListItem } from './chunk-GUIHMIT2.js';
4
+ import { List } from './chunk-3NBL3OUQ.js';
5
+ import { ToolbarText } from './chunk-TZ5IHWB2.js';
6
6
  import { Pane } from './chunk-7GUQYHKA.js';
7
7
 
8
8
 
9
- import { ListHeader } from './chunk-3S7OUHUN.js';
9
+ import { ListHeader } from './chunk-KPNTVB7A.js';
10
10
 
11
11
 
12
12
  import { filterDataAttributes } from './chunk-SRSJO5QE.js';