@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.
- package/README.md +1 -0
- package/ai/application-ui-diagnostic-ids-v1.json +1 -0
- package/ai/component-catalog-v2.d.ts +3 -0
- package/ai/component-catalog-v2.json +85 -7
- package/ai/component-catalog-v2.schema.json +9 -1
- package/ai/component-catalog.json +108 -11
- package/ai/public-api-signatures-v1.md +215 -25
- package/ai/skill.md +23 -17
- package/ai/webawesome-jsx-signatures-v1.md +1 -1
- package/analyzer/index.mjs +48 -0
- package/analyzer/loud-pairs.d.mts +49 -0
- package/analyzer/loud-pairs.mjs +357 -0
- package/dist/app-tab.d.ts +7 -5
- package/dist/app-tab.js +1 -1
- package/dist/browser/segmented-control.js +0 -1
- package/dist/catalog.js +4 -4
- package/dist/{chunk-EKYZCRCX.js → chunk-3CGQ7ZNO.js} +54 -22
- package/dist/chunk-3CGQ7ZNO.js.map +1 -0
- package/dist/{chunk-7XJO4D6A.js → chunk-3NBL3OUQ.js} +2 -2
- package/dist/{chunk-7XJO4D6A.js.map → chunk-3NBL3OUQ.js.map} +1 -1
- package/dist/{chunk-2S7OWAEV.js → chunk-44IG7PFV.js} +3 -3
- package/dist/{chunk-2S7OWAEV.js.map → chunk-44IG7PFV.js.map} +1 -1
- package/dist/{chunk-UKKSMZYT.js → chunk-46263QLK.js} +51 -1
- package/dist/chunk-46263QLK.js.map +1 -0
- package/dist/{chunk-DNCOFEUL.js → chunk-5I22LMCV.js} +2 -2
- package/dist/{chunk-DNCOFEUL.js.map → chunk-5I22LMCV.js.map} +1 -1
- package/dist/{chunk-NDUGJ4TU.js → chunk-5NDQABWK.js} +1 -1
- package/dist/{chunk-NDUGJ4TU.js.map → chunk-5NDQABWK.js.map} +1 -1
- package/dist/{chunk-KAQUUTZR.js → chunk-ATHXW6RP.js} +2 -2
- package/dist/{chunk-KAQUUTZR.js.map → chunk-ATHXW6RP.js.map} +1 -1
- package/dist/{chunk-JGFZ42ZF.js → chunk-BJJKFVWW.js} +14 -5
- package/dist/chunk-BJJKFVWW.js.map +1 -0
- package/dist/{chunk-T7C2VZUB.js → chunk-C2FAYFDE.js} +2 -2
- package/dist/{chunk-T7C2VZUB.js.map → chunk-C2FAYFDE.js.map} +1 -1
- package/dist/{chunk-SV22UIW4.js → chunk-DQA5FMOX.js} +2 -2
- package/dist/{chunk-SV22UIW4.js.map → chunk-DQA5FMOX.js.map} +1 -1
- package/dist/{chunk-LIOXPQ7N.js → chunk-GUIHMIT2.js} +12 -10
- package/dist/chunk-GUIHMIT2.js.map +1 -0
- package/dist/{chunk-3S7OUHUN.js → chunk-KPNTVB7A.js} +9 -4
- package/dist/chunk-KPNTVB7A.js.map +1 -0
- package/dist/{chunk-IR53BQUJ.js → chunk-NE5JAOQS.js} +9 -9
- package/dist/chunk-NE5JAOQS.js.map +1 -0
- package/dist/chunk-PSHUSWQH.js +18 -0
- package/dist/chunk-PSHUSWQH.js.map +1 -0
- package/dist/{chunk-OSCL5TKR.js → chunk-RNZAKSME.js} +11 -8
- package/dist/chunk-RNZAKSME.js.map +1 -0
- package/dist/{chunk-ABAXNUXU.js → chunk-T22EM4ET.js} +7 -5
- package/dist/chunk-T22EM4ET.js.map +1 -0
- package/dist/{chunk-YZV2PTY4.js → chunk-TZ5IHWB2.js} +3 -3
- package/dist/{chunk-YZV2PTY4.js.map → chunk-TZ5IHWB2.js.map} +1 -1
- package/dist/{chunk-VOCPXGQN.js → chunk-VHADGP3B.js} +9 -8
- package/dist/chunk-VHADGP3B.js.map +1 -0
- package/dist/{chunk-2CE22Q2K.js → chunk-WZ2GLEY4.js} +2 -1
- package/dist/chunk-WZ2GLEY4.js.map +1 -0
- package/dist/{chunk-M6GLI6A6.js → chunk-X5QSICJ3.js} +5 -5
- package/dist/chunk-X5QSICJ3.js.map +1 -0
- package/dist/chunk-XQV2B4IL.js +14 -0
- package/dist/chunk-XQV2B4IL.js.map +1 -0
- package/dist/{chunk-4XE2CDYB.js → chunk-ZMBU522F.js} +2 -2
- package/dist/{chunk-4XE2CDYB.js.map → chunk-ZMBU522F.js.map} +1 -1
- package/dist/collapsible-panel.d.ts +6 -2
- package/dist/collapsible-panel.js +1 -0
- package/dist/collapsible-panel.js.map +1 -1
- package/dist/css-values.d.ts +1 -1
- package/dist/css-values.js +1 -1
- package/dist/floating-toolbar.d.ts +5 -2
- package/dist/floating-toolbar.js +1 -1
- package/dist/grid.js +1 -1
- package/dist/index.js +20 -19
- package/dist/list-action-row.d.ts +2 -1
- package/dist/list-action-row.js +1 -1
- package/dist/list-header.d.ts +1 -0
- package/dist/list-header.js +1 -1
- package/dist/list-item.d.ts +5 -3
- package/dist/list-item.js +1 -1
- package/dist/list.js +1 -1
- package/dist/nav-stack.js +1 -1
- package/dist/resizable-region.d.ts +4 -1
- package/dist/resizable-region.js +1 -1
- package/dist/row.js +1 -1
- package/dist/segmented-control.d.ts +1 -1
- package/dist/segmented-control.js +1 -3
- package/dist/select.js +1 -1
- package/dist/skeleton.js +1 -1
- package/dist/spacer.js +1 -1
- package/dist/split-view.js +2 -2
- package/dist/state-banner.d.ts +1 -1
- package/dist/state-banner.js +1 -1
- package/dist/styles/badge.css +17 -6
- package/dist/styles/collapsible-panel.css +89 -11
- package/dist/styles/floating-toolbar.css +11 -6
- package/dist/styles/foundation.css +82 -26
- package/dist/styles/list-action-row.css +29 -8
- package/dist/styles/list-header.css +19 -7
- package/dist/styles/list-item.css +11 -3
- package/dist/styles/resizable-region.css +82 -14
- package/dist/styles/segmented-control.css +21 -1
- package/dist/styles/select.css +24 -13
- package/dist/styles/skeleton.css +33 -2
- package/dist/styles/state-banner.css +48 -12
- package/dist/styles/webawesome.css +47 -20
- package/dist/styles/workbench.css +473 -11
- package/dist/toolbar-text.js +1 -1
- package/dist/value-table.js +1 -1
- package/dist/wire-resizable-regions.d.ts +2 -1
- package/dist/wire-resizable-regions.js +2 -1
- package/dist/wire-tab-bars.js +1 -1
- package/dist/wire-workbench.d.ts +109 -0
- package/dist/wire-workbench.js +355 -0
- package/dist/wire-workbench.js.map +1 -0
- package/dist/workbench.d.ts +65 -6
- package/dist/workbench.js +180 -76
- package/dist/workbench.js.map +1 -1
- package/docs/accessibility.md +54 -9
- package/docs/app-layouts.md +12 -1
- package/docs/collapsible-panel.md +59 -1
- package/docs/component-contract.md +76 -4
- package/docs/component-selection.md +16 -6
- package/docs/design/templates/empty-state/with-action-dark.svg +1 -1
- package/docs/design/templates/empty-state-dark.svg +1 -1
- package/docs/design/templates/list/stack-dark.svg +1 -1
- package/docs/design/templates/list/stack.svg +1 -1
- package/docs/design/templates/list-action-row/selected.svg +1 -1
- package/docs/design/templates/list-action-row.svg +1 -1
- package/docs/design/templates/list-dark.svg +1 -1
- package/docs/design/templates/list-header/action-dark.svg +1 -1
- package/docs/design/templates/list-header/action.svg +1 -1
- package/docs/design/templates/list-header/count-dark.svg +1 -1
- package/docs/design/templates/list-header/count.svg +1 -1
- package/docs/design/templates/list-header/inline-action-dark.svg +1 -1
- package/docs/design/templates/list-header/inline-action.svg +1 -1
- package/docs/design/templates/list-header/toggle-expanded-dark.svg +1 -1
- package/docs/design/templates/list-header/toggle-expanded.svg +1 -1
- package/docs/design/templates/list-header-dark.svg +1 -1
- package/docs/design/templates/list-header.svg +1 -1
- package/docs/design/templates/list-item/selected.svg +1 -1
- package/docs/design/templates/list-item.svg +1 -1
- package/docs/design/templates/list.svg +1 -1
- package/docs/design/templates/segmented-control/equal-dark.svg +1 -1
- package/docs/design/templates/segmented-control/equal.svg +1 -1
- package/docs/design/templates/segmented-control/pill-small-dark.svg +1 -1
- package/docs/design/templates/segmented-control/pill-small.svg +1 -1
- package/docs/design/templates/segmented-control-dark.svg +1 -1
- package/docs/design/templates/segmented-control.svg +1 -1
- package/docs/design/templates/state-banner/danger-dark.svg +1 -1
- package/docs/design/templates/state-banner/danger.svg +1 -1
- package/docs/design/templates/state-banner/info-badge-dark.svg +1 -1
- package/docs/design/templates/state-banner/info-badge.svg +1 -1
- package/docs/design/templates/state-banner/info-dark.svg +1 -1
- package/docs/design/templates/state-banner/info.svg +1 -1
- package/docs/design/templates/state-banner/success-dark.svg +1 -1
- package/docs/design/templates/state-banner/success.svg +1 -1
- package/docs/design/templates/state-banner/warning-dark.svg +1 -1
- package/docs/design/templates/state-banner/warning.svg +1 -1
- package/docs/design/templates/state-banner-dark.svg +1 -1
- package/docs/design/templates/state-banner.svg +1 -1
- package/docs/design/templates/tab-bar/workspace-tabs-dark.svg +1 -1
- package/docs/design/templates/tab-bar/workspace-tabs.svg +1 -1
- package/docs/design/templates/tab-bar-dark.svg +1 -1
- package/docs/design/templates/tab-bar.svg +1 -1
- package/docs/design/templates/token-search-field/tokens.svg +1 -1
- package/docs/design/templates/token-search-field.svg +1 -1
- package/docs/design/templates.md +11 -1
- package/docs/device-class.md +2 -1
- package/docs/document-baseline.md +4 -0
- package/docs/layout.md +5 -1
- package/docs/recipes.md +5 -3
- package/docs/type-contracts.md +2 -1
- package/docs/ui-analyzer.md +94 -19
- package/docs/ui-doctor.md +6 -0
- package/docs/ux-demo.md +2 -2
- package/docs/webawesome-theme.md +18 -2
- package/docs/workbench.md +302 -7
- package/llms.txt +14 -6
- package/package.json +5 -1
- package/ux-demo/recipes/collapsible-sidebar.tsx +8 -7
- package/ux-demo/recipes/loading-inspector.tsx +3 -2
- package/dist/chunk-2CE22Q2K.js.map +0 -1
- package/dist/chunk-3S7OUHUN.js.map +0 -1
- package/dist/chunk-ABAXNUXU.js.map +0 -1
- package/dist/chunk-EKYZCRCX.js.map +0 -1
- package/dist/chunk-IR53BQUJ.js.map +0 -1
- package/dist/chunk-JGFZ42ZF.js.map +0 -1
- package/dist/chunk-LIOXPQ7N.js.map +0 -1
- package/dist/chunk-M6GLI6A6.js.map +0 -1
- package/dist/chunk-OSCL5TKR.js.map +0 -1
- package/dist/chunk-UKKSMZYT.js.map +0 -1
- package/dist/chunk-VOCPXGQN.js.map +0 -1
package/analyzer/index.mjs
CHANGED
|
@@ -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
|
-
*
|
|
37
|
-
* visible in the quiet text color and as the tab's accessible
|
|
38
|
-
* `trailing` (for example a `LoadingSpinner`), and
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
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-
|
|
4
|
-
import { List } from './chunk-
|
|
5
|
-
import { ToolbarText } from './chunk-
|
|
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-
|
|
9
|
+
import { ListHeader } from './chunk-KPNTVB7A.js';
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
import { filterDataAttributes } from './chunk-SRSJO5QE.js';
|