@kerfjs/ui 5.0.0-beta.53 → 5.0.0-beta.55

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 (152) hide show
  1. package/README.md +4 -2
  2. package/ai/application-ui-diagnostic-ids-v1.json +1 -0
  3. package/ai/component-catalog-v2.json +138 -5
  4. package/ai/component-catalog-v2.schema.json +2 -1
  5. package/ai/component-catalog.json +208 -16
  6. package/ai/public-api-signatures-v1.md +462 -39
  7. package/ai/skill.md +28 -20
  8. package/ai/webawesome-jsx-signatures-v1.md +1 -1
  9. package/analyzer/index.mjs +48 -0
  10. package/analyzer/loud-pairs.d.mts +49 -0
  11. package/analyzer/loud-pairs.mjs +357 -0
  12. package/dist/app-tab.d.ts +7 -5
  13. package/dist/app-tab.js +1 -1
  14. package/dist/browser/popup-menu.js +3 -0
  15. package/dist/catalog-resources.d.ts +1 -1
  16. package/dist/catalog.d.ts +10 -4
  17. package/dist/catalog.js +243 -300
  18. package/dist/catalog.js.map +1 -1
  19. package/dist/{chunk-7IUSV4XG.js → chunk-24PHKUR3.js} +2 -2
  20. package/dist/chunk-24PHKUR3.js.map +1 -0
  21. package/dist/{chunk-EKYZCRCX.js → chunk-3CGQ7ZNO.js} +54 -22
  22. package/dist/chunk-3CGQ7ZNO.js.map +1 -0
  23. package/dist/{chunk-UKKSMZYT.js → chunk-46263QLK.js} +51 -1
  24. package/dist/chunk-46263QLK.js.map +1 -0
  25. package/dist/chunk-4BIMLITI.js +92 -0
  26. package/dist/chunk-4BIMLITI.js.map +1 -0
  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-ZK5EGVFP.js → chunk-5OLVUEND.js} +12 -2
  30. package/dist/chunk-5OLVUEND.js.map +1 -0
  31. package/dist/{chunk-CMUIXJZM.js → chunk-BSKEH3UC.js} +2 -2
  32. package/dist/{chunk-CMUIXJZM.js.map → chunk-BSKEH3UC.js.map} +1 -1
  33. package/dist/chunk-EAX2GSS4.js +384 -0
  34. package/dist/chunk-EAX2GSS4.js.map +1 -0
  35. package/dist/chunk-KH6VUGHU.js +399 -0
  36. package/dist/chunk-KH6VUGHU.js.map +1 -0
  37. package/dist/{chunk-BJJKFVWW.js → chunk-LZXQVUGN.js} +2 -2
  38. package/dist/chunk-LZXQVUGN.js.map +1 -0
  39. package/dist/chunk-N7FHFNT6.js +55 -0
  40. package/dist/chunk-N7FHFNT6.js.map +1 -0
  41. package/dist/{chunk-CCMKJWLX.js → chunk-NE5JAOQS.js} +7 -7
  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-KPNTVB7A.js → chunk-R754MHXX.js} +3 -3
  46. package/dist/{chunk-KPNTVB7A.js.map → chunk-R754MHXX.js.map} +1 -1
  47. package/dist/{chunk-HJKRCKDR.js → chunk-RNRUVPJ3.js} +4 -2
  48. package/dist/chunk-RNRUVPJ3.js.map +1 -0
  49. package/dist/{chunk-VOCPXGQN.js → chunk-VHADGP3B.js} +9 -8
  50. package/dist/chunk-VHADGP3B.js.map +1 -0
  51. package/dist/chunk-XQV2B4IL.js +14 -0
  52. package/dist/chunk-XQV2B4IL.js.map +1 -0
  53. package/dist/collapsible-panel.d.ts +46 -15
  54. package/dist/collapsible-panel.js +42 -10
  55. package/dist/collapsible-panel.js.map +1 -1
  56. package/dist/floating-toolbar.d.ts +5 -2
  57. package/dist/floating-toolbar.js +1 -1
  58. package/dist/index.d.ts +1 -0
  59. package/dist/index.js +20 -18
  60. package/dist/list-header.js +1 -1
  61. package/dist/list-inset-text.d.ts +7 -1
  62. package/dist/list-inset-text.js +2 -1
  63. package/dist/panel-toolbar-BFQxFITu.d.ts +52 -0
  64. package/dist/popup-menu-register.d.ts +9 -0
  65. package/dist/popup-menu-register.js +30 -0
  66. package/dist/popup-menu-register.js.map +1 -0
  67. package/dist/popup-menu.d.ts +71 -0
  68. package/dist/popup-menu.js +3 -0
  69. package/dist/popup-menu.js.map +1 -0
  70. package/dist/resizable-region.d.ts +4 -1
  71. package/dist/resizable-region.js +1 -1
  72. package/dist/split-view.js +1 -1
  73. package/dist/state-banner.js +1 -1
  74. package/dist/styles/catalog/components/catalog-example.css +0 -16
  75. package/dist/styles/catalog/components/catalog-stage.css +8 -13
  76. package/dist/styles/catalog/components/catalog.css +8 -18
  77. package/dist/styles/catalog.css +0 -5
  78. package/dist/styles/collapsible-panel.css +88 -10
  79. package/dist/styles/floating-toolbar.css +11 -6
  80. package/dist/styles/popup-menu.css +12 -0
  81. package/dist/styles/resizable-region.css +81 -13
  82. package/dist/styles/state-banner.css +13 -4
  83. package/dist/styles/styles.css +1 -0
  84. package/dist/styles/tab-bar.css +31 -3
  85. package/dist/styles/toolbar-control-group.css +13 -0
  86. package/dist/styles/toolbar.css +43 -2
  87. package/dist/styles/workbench.css +503 -10
  88. package/dist/text.js +1 -1
  89. package/dist/token-search-field.d.ts +10 -1
  90. package/dist/token-search-field.js +1 -1
  91. package/dist/{types-CEsieBZi.d.ts → types-D6vzj-VT.d.ts} +15 -0
  92. package/dist/wire-catalog.d.ts +13 -1
  93. package/dist/wire-catalog.js +18 -3
  94. package/dist/wire-catalog.js.map +1 -1
  95. package/dist/wire-resizable-regions.d.ts +2 -1
  96. package/dist/wire-resizable-regions.js +2 -1
  97. package/dist/wire-sidebar.js +6 -3
  98. package/dist/wire-sidebar.js.map +1 -1
  99. package/dist/wire-tab-bars.js +1 -1
  100. package/dist/wire-token-search-fields.js +1 -1
  101. package/dist/wire-workbench.d.ts +109 -0
  102. package/dist/wire-workbench.js +6 -0
  103. package/dist/wire-workbench.js.map +1 -0
  104. package/dist/workbench.d.ts +166 -6
  105. package/dist/workbench.js +11 -104
  106. package/dist/workbench.js.map +1 -1
  107. package/docs/accessibility.md +66 -10
  108. package/docs/app-layouts.md +12 -1
  109. package/docs/catalog.md +27 -9
  110. package/docs/collapsible-panel.md +114 -1
  111. package/docs/component-contract.md +2 -2
  112. package/docs/component-selection.md +41 -23
  113. package/docs/design/templates/state-banner/danger-dark.svg +1 -1
  114. package/docs/design/templates/state-banner/danger.svg +1 -1
  115. package/docs/design/templates/state-banner/info-dark.svg +1 -1
  116. package/docs/design/templates/state-banner/info.svg +1 -1
  117. package/docs/design/templates/state-banner/success-dark.svg +1 -1
  118. package/docs/design/templates/state-banner/success.svg +1 -1
  119. package/docs/design/templates/state-banner/warning-dark.svg +1 -1
  120. package/docs/design/templates/state-banner/warning.svg +1 -1
  121. package/docs/design/templates/state-banner-dark.svg +1 -1
  122. package/docs/design/templates/state-banner.svg +1 -1
  123. package/docs/document-baseline.md +4 -0
  124. package/docs/layout.md +10 -1
  125. package/docs/recipes.md +48 -39
  126. package/docs/surface-scaffold.md +1 -1
  127. package/docs/type-contracts.md +2 -1
  128. package/docs/ui-analyzer.md +94 -19
  129. package/docs/ui-doctor.md +6 -0
  130. package/docs/ux-demo.md +7 -3
  131. package/docs/webawesome-theme.md +11 -2
  132. package/docs/workbench.md +388 -8
  133. package/llms.txt +13 -8
  134. package/package.json +17 -2
  135. package/ux-demo/recipes/app-shell.tsx +141 -223
  136. package/ux-demo/recipes/collapsible-sidebar.tsx +48 -78
  137. package/ux-demo/recipes/list-detail-dialog.tsx +150 -54
  138. package/ux-demo/recipes/mount-recipe.ts +0 -5
  139. package/ux-demo/recipes/types.ts +0 -1
  140. package/dist/chunk-7IUSV4XG.js.map +0 -1
  141. package/dist/chunk-BJJKFVWW.js.map +0 -1
  142. package/dist/chunk-CCMKJWLX.js.map +0 -1
  143. package/dist/chunk-EKYZCRCX.js.map +0 -1
  144. package/dist/chunk-HJKRCKDR.js.map +0 -1
  145. package/dist/chunk-UKKSMZYT.js.map +0 -1
  146. package/dist/chunk-VOCPXGQN.js.map +0 -1
  147. package/dist/chunk-ZK5EGVFP.js.map +0 -1
  148. package/dist/styles/catalog/components/catalog-detail.css +0 -35
  149. package/dist/styles/catalog/components/catalog-resource-footer.css +0 -37
  150. package/dist/styles/catalog/components/catalog-secondary-sections.css +0 -38
  151. package/dist/styles/catalog/components/catalog-section-list.css +0 -22
  152. package/dist/styles/catalog/components/catalog-sidebar.css +0 -82
@@ -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-CCMKJWLX.js';
1
+ export { AppTab } from './chunk-NE5JAOQS.js';
2
2
 
3
3
 
4
4
 
@@ -0,0 +1,3 @@
1
+ import '../styles/foundation.css';
2
+ import '../styles/popup-menu.css';
3
+ export * from '../popup-menu.js';
@@ -1,4 +1,4 @@
1
- import { C as CatalogResource } from './types-CEsieBZi.js';
1
+ import { C as CatalogResource } from './types-D6vzj-VT.js';
2
2
  import './semantic-content-BbzjvSu9.js';
3
3
  import 'kerfjs';
4
4
 
package/dist/catalog.d.ts CHANGED
@@ -1,11 +1,17 @@
1
1
  import * as kerfjs from 'kerfjs';
2
2
  import { SafeHtml } from 'kerfjs';
3
- import { a as CatalogProps } from './types-CEsieBZi.js';
4
- export { b as CatalogBrand, c as CatalogEntry, d as CatalogRelated, C as CatalogResource, e as CatalogSecondaryGroup, f as CatalogSection, g as CatalogStageRootAttributes } from './types-CEsieBZi.js';
3
+ import { a as CatalogProps } from './types-D6vzj-VT.js';
4
+ export { b as CatalogBrand, c as CatalogEntry, d as CatalogRelated, C as CatalogResource, e as CatalogSecondaryGroup, f as CatalogSection, g as CatalogStageRootAttributes } from './types-D6vzj-VT.js';
5
5
  import { K as KerfUiContent } from './semantic-content-BbzjvSu9.js';
6
6
 
7
- /** Controlled, stateless component-catalog shell. */
8
- declare function Catalog({ brand, sections, active, content, collapsed, theme, headerActions, secondarySections, sidebarFooter, status, geometryOverlay, stageRootAttributes, selectAction, toggleSidebarAction, toggleThemeAction, toggleSecondaryAction, className, slot, }: CatalogProps): kerfjs.SafeHtml;
7
+ /**
8
+ * Controlled, stateless component-catalog shell: a `Workbench` whose left rail
9
+ * is the catalog navigation and whose work area is the active entry — its
10
+ * toolbar, description, preview stage, and resource footer. The sidebar's
11
+ * standard toggle moves into the entry toolbar while it is collapsed, and on
12
+ * a small screen the sidebar overlays the stage like any Workbench rail.
13
+ */
14
+ declare function Catalog({ id, brand, sections, active, content, collapsed, theme, headerActions, secondarySections, sidebarFooter, status, geometryOverlay, stageRootAttributes, selectAction, toggleSidebarAction, toggleThemeAction, toggleSecondaryAction, headerPlacement, footerPlacement, className, slot, }: CatalogProps): kerfjs.SafeHtml;
9
15
 
10
16
  /** How a specimen aligns its visible edge with its `ListHeader` label. */
11
17
  type CatalogExampleAlign = 'glyph' | 'inline-control' | 'none';