@motion-proto/live-tokens 0.96.0 → 0.98.2
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/.claude/skills/live-tokens-check-compliance/SKILL.md +5 -0
- package/.claude/skills/live-tokens-create-component/SKILL.md +4 -3
- package/.claude/skills/live-tokens-create-component/references/contract-tests.md +71 -11
- package/CHANGELOG.md +309 -0
- package/README.md +2 -2
- package/bin/check-component.mjs +8 -0
- package/bin/check-page.mjs +6 -0
- package/bin/cli.mjs +36 -6
- package/bin/contractRunner.mjs +142 -17
- package/bin/lib/findings.mjs +20 -4
- package/bin/rules/a11y.mjs +53 -0
- package/bin/set-colors.mjs +1 -0
- package/bin/setup-claude.mjs +2 -2
- package/dist-plugin/{chunk-A4TBP4MM.js → chunk-SLICH4IH.js} +2 -2
- package/dist-plugin/index.cjs +148 -3
- package/dist-plugin/index.js +250 -105
- package/dist-plugin/setColors/index.cjs +7 -1
- package/dist-plugin/setColors/index.d.cts +9 -4
- package/dist-plugin/setColors/index.d.ts +9 -4
- package/dist-plugin/setColors/index.js +8 -2
- package/dist-plugin/setGeometry/index.d.cts +1 -1
- package/dist-plugin/setGeometry/index.d.ts +1 -1
- package/dist-plugin/setType/index.d.cts +1 -1
- package/dist-plugin/setType/index.d.ts +1 -1
- package/dist-plugin/{themeTypes-B8_Idrp4.d.cts → themeTypes-BaTQUyac.d.cts} +10 -1
- package/dist-plugin/{themeTypes-B8_Idrp4.d.ts → themeTypes-BaTQUyac.d.ts} +10 -1
- package/package.json +49 -2
- package/src/demo/LabeledSelect.svelte +1 -1
- package/src/editor/bootstrap.ts +3 -1
- package/src/editor/component-editor/BadgeEditor.svelte +13 -0
- package/src/editor/component-editor/ButtonEditor.svelte +33 -2
- package/src/editor/component-editor/CalloutEditor.svelte +13 -0
- package/src/editor/component-editor/CardEditor.svelte +12 -2
- package/src/editor/component-editor/CodeSnippetEditor.svelte +11 -1
- package/src/editor/component-editor/CollapsibleSectionEditor.svelte +18 -1
- package/src/editor/component-editor/CornerBadgeEditor.svelte +13 -0
- package/src/editor/component-editor/DialogEditor.svelte +15 -0
- package/src/editor/component-editor/IconButtonEditor.svelte +29 -1
- package/src/editor/component-editor/ImageEditor.svelte +20 -9
- package/src/editor/component-editor/ImageLightboxEditor.svelte +44 -0
- package/src/editor/component-editor/InlineEditActionsEditor.svelte +13 -1
- package/src/editor/component-editor/InputEditor.svelte +23 -0
- package/src/editor/component-editor/MenuSelectEditor.svelte +27 -1
- package/src/editor/component-editor/NotificationEditor.svelte +15 -0
- package/src/editor/component-editor/PanelEditor.svelte +1 -1
- package/src/editor/component-editor/ProgressBarEditor.svelte +13 -0
- package/src/editor/component-editor/RadioButtonEditor.svelte +20 -1
- package/src/editor/component-editor/SectionDividerEditor.svelte +41 -13
- package/src/editor/component-editor/SegmentedControlEditor.svelte +29 -1
- package/src/editor/component-editor/SideNavigationEditor.svelte +40 -1
- package/src/editor/component-editor/SlidePagerEditor.svelte +20 -0
- package/src/editor/component-editor/SliderEditor.svelte +33 -0
- package/src/editor/component-editor/TabBarEditor.svelte +23 -2
- package/src/editor/component-editor/TableEditor.svelte +14 -0
- package/src/editor/component-editor/ToggleEditor.svelte +17 -0
- package/src/editor/component-editor/TooltipEditor.svelte +11 -2
- package/src/editor/component-editor/VideoFrameEditor.svelte +8 -0
- package/src/editor/component-editor/VideoLightboxEditor.svelte +10 -0
- package/src/editor/component-editor/registration.ts +2 -1
- package/src/editor/component-editor/registry.ts +69 -27
- package/src/editor/component-editor/scaffolding/DemoHeader.svelte +2 -0
- package/src/editor/component-editor/scaffolding/StateBlock.svelte +19 -8
- package/src/editor/component-editor/scaffolding/TokenLayout.svelte +3 -0
- package/src/editor/component-editor/scaffolding/VariantGroup.svelte +70 -10
- package/src/editor/component-editor/scaffolding/editorContext.ts +7 -0
- package/src/editor/core/a11y/a11yReport.ts +101 -0
- package/src/editor/core/a11y/a11yReportTypes.ts +69 -0
- package/src/editor/core/a11y/contrastFailures.ts +22 -0
- package/src/editor/core/components/contrastPairRegistry.ts +18 -0
- package/src/editor/core/palettes/contrast.ts +44 -0
- package/src/editor/core/palettes/contrastPairs.ts +79 -0
- package/src/editor/core/palettes/resolveColor.ts +77 -0
- package/src/editor/core/store/editorPersistence.ts +9 -1
- package/src/editor/core/store/editorRenderer.ts +2 -0
- package/src/editor/core/store/editorStore.ts +29 -0
- package/src/editor/core/store/editorTypes.ts +9 -0
- package/src/editor/core/store/editorViewStore.ts +2 -2
- package/src/editor/core/store/unsavedEdits.ts +10 -0
- package/src/editor/core/themes/buildColors.ts +16 -5
- package/src/editor/core/themes/liveStateStream.ts +7 -1
- package/src/editor/core/themes/parsers/gradient.ts +5 -2
- package/src/editor/core/themes/slices/customColors.ts +226 -0
- package/src/editor/core/themes/themeTypes.ts +10 -0
- package/src/editor/docs/chapters.ts +1 -0
- package/src/editor/docs/content/accessibility.md +362 -0
- package/src/editor/docs/content/editing-tokens.md +56 -1
- package/src/editor/docs/content.generated.ts +2 -1
- package/src/editor/pages/ComponentEditorPage.svelte +10 -1
- package/src/editor/pages/EditorShell.svelte +97 -10
- package/src/editor/skill-atlas/skillSources.generated.ts +3 -3
- package/src/editor/skill-atlas/trees/check-compliance.ts +3 -3
- package/src/editor/skill-atlas/trees/create-component.ts +12 -12
- package/src/editor/ui/CustomColorReplaceStep.svelte +118 -0
- package/src/editor/ui/EditorViewSwitcher.svelte +15 -2
- package/src/editor/ui/GradientEditor.svelte +9 -1
- package/src/editor/ui/ThemePanel.svelte +4 -4
- package/src/editor/ui/UIContrastReadout.svelte +186 -0
- package/src/editor/ui/UIInfoPopover.svelte +16 -2
- package/src/editor/ui/UIPaletteSelector.svelte +347 -15
- package/src/editor/ui/UITokenSelector.svelte +33 -14
- package/src/editor/ui/VariablesTab.svelte +3 -0
- package/src/editor/ui/a11y/A11yComponentIssues.svelte +103 -0
- package/src/editor/ui/a11y/A11yFindingRow.svelte +57 -0
- package/src/editor/ui/a11y/A11yIssueRow.svelte +121 -0
- package/src/editor/ui/a11y/A11yOverview.svelte +242 -0
- package/src/editor/ui/a11y/A11yPanel.svelte +128 -0
- package/src/editor/ui/a11y/A11yPropertyMark.svelte +84 -0
- package/src/editor/ui/a11y/A11yRailMark.svelte +34 -0
- package/src/editor/ui/a11y/AccessibilityTab.svelte +334 -0
- package/src/editor/ui/a11y/a11yView.ts +218 -0
- package/src/editor/ui/anchorToTrigger.ts +63 -0
- package/src/editor/ui/contrastReadout.ts +45 -0
- package/src/editor/ui/sections/CustomColorsSection.svelte +254 -0
- package/src/live-tokens/data/themes/autumn.json +20 -20
- package/src/live-tokens/data/themes/halloween.json +20 -20
- package/src/live-tokens/data/themes/midnight-study.json +20 -20
- package/src/live-tokens/data/themes/ocean.json +20 -20
- package/src/live-tokens/data/themes/royal-velvet.json +20 -20
- package/src/live-tokens/data/themes/sketchy.json +20 -20
- package/src/live-tokens/data/themes/spring-meadow.json +20 -20
- package/src/live-tokens/data/themes/sunset.json +20 -20
- package/src/system/components/Badge.svelte +3 -3
- package/src/system/components/Button.svelte +9 -3
- package/src/system/components/CodeSnippet.svelte +8 -1
- package/src/system/components/CornerBadge.svelte +3 -3
- package/src/system/components/Dialog.svelte +43 -14
- package/src/system/components/IconButton.svelte +1 -1
- package/src/system/components/Image.svelte +7 -0
- package/src/system/components/ImageLightbox.svelte +26 -26
- package/src/system/components/Input.svelte +8 -1
- package/src/system/components/MenuSelect.svelte +30 -2
- package/src/system/components/Notification.svelte +1 -1
- package/src/system/components/ProgressBar.svelte +6 -0
- package/src/system/components/RadioButton.svelte +36 -3
- package/src/system/components/SegmentedControl.svelte +36 -2
- package/src/system/components/SlidePager.svelte +2 -2
- package/src/system/components/Slider.svelte +13 -9
- package/src/system/components/TabBar.svelte +33 -3
- package/src/system/components/Toggle.svelte +2 -2
- package/src/system/components/VideoFrame.svelte +1 -1
- package/src/system/components/VideoLightbox.svelte +2 -2
- package/src/system/internal/focusTrap.ts +35 -0
- package/src/testing-js/__aria__/badge.yml +40 -0
- package/src/testing-js/__aria__/button.yml +88 -0
- package/src/testing-js/__aria__/callout.yml +12 -0
- package/src/testing-js/__aria__/card.yml +8 -0
- package/src/testing-js/__aria__/codesnippet.yml +21 -0
- package/src/testing-js/__aria__/collapsiblesection.yml +23 -0
- package/src/testing-js/__aria__/cornerbadge.yml +24 -0
- package/src/testing-js/__aria__/dialog.yml +42 -0
- package/src/testing-js/__aria__/iconbutton.yml +57 -0
- package/src/testing-js/__aria__/image.yml +10 -0
- package/src/testing-js/__aria__/imagelightbox.yml +50 -0
- package/src/testing-js/__aria__/inlineeditactions.yml +16 -0
- package/src/testing-js/__aria__/input.yml +61 -0
- package/src/testing-js/__aria__/menuselect.yml +30 -0
- package/src/testing-js/__aria__/notification.yml +30 -0
- package/src/testing-js/__aria__/panel.yml +2 -0
- package/src/testing-js/__aria__/progressbar.yml +2 -0
- package/src/testing-js/__aria__/radiobutton.yml +20 -0
- package/src/testing-js/__aria__/sectiondivider.yml +9 -0
- package/src/testing-js/__aria__/segmentedcontrol.yml +35 -0
- package/src/testing-js/__aria__/sidenavigation.yml +304 -0
- package/src/testing-js/__aria__/slidepager.yml +72 -0
- package/src/testing-js/__aria__/slider.yml +28 -0
- package/src/testing-js/__aria__/tabbar.yml +35 -0
- package/src/testing-js/__aria__/table.yml +144 -0
- package/src/testing-js/__aria__/toggle.yml +12 -0
- package/src/testing-js/__aria__/tooltip.yml +2 -0
- package/src/testing-js/__aria__/videoframe.yml +6 -0
- package/src/testing-js/__aria__/videolightbox.yml +12 -0
- package/src/testing-js/{chunk-WIZ6W7UT.js → chunk-3WCLMOM7.js} +2 -2
- package/src/testing-js/{chunk-SHBYZUPD.js → chunk-5HNZHIHG.js} +211 -14
- package/src/testing-js/chunk-5HNZHIHG.js.map +1 -0
- package/src/testing-js/{chunk-NB3NZRBM.js → chunk-7CM5D7PF.js} +5 -1
- package/src/testing-js/chunk-7CM5D7PF.js.map +1 -0
- package/src/testing-js/chunk-EWSGE27V.js +168 -0
- package/src/testing-js/chunk-EWSGE27V.js.map +1 -0
- package/src/testing-js/{chunk-GKO27NGN.js → chunk-R54I7YL6.js} +2 -2
- package/src/testing-js/{chunk-X6KHEVIG.js → chunk-RWH46IU5.js} +97 -29
- package/src/testing-js/chunk-RWH46IU5.js.map +1 -0
- package/src/testing-js/chunk-SHLPDNAQ.js +658 -0
- package/src/testing-js/chunk-SHLPDNAQ.js.map +1 -0
- package/src/testing-js/{chunk-3UKGXCDL.js → chunk-YVVR3GJZ.js} +1 -1
- package/src/testing-js/chunk-YVVR3GJZ.js.map +1 -0
- package/src/testing-js/component-a11y.contract.js +283 -0
- package/src/testing-js/component-a11y.contract.js.map +1 -0
- package/src/testing-js/component-alias.contract.js +2 -2
- package/src/testing-js/component-behavior.contract.js +2 -2
- package/src/testing-js/component-editor.contract.js +6 -5
- package/src/testing-js/component-editor.contract.js.map +1 -1
- package/src/testing-js/component-render.contract.js +10 -6
- package/src/testing-js/component-render.contract.js.map +1 -1
- package/src/testing-js/index.d.ts +44 -16
- package/src/testing-js/index.js +9 -9
- package/src/testing-js/page-compliance.contract.js +163 -18
- package/src/testing-js/page-compliance.contract.js.map +1 -1
- package/src/testing-js/vitest.js +3 -3
- package/template/package.json +9 -0
- package/src/editor/ui/keepInViewport.ts +0 -84
- package/src/testing-js/chunk-3UKGXCDL.js.map +0 -1
- package/src/testing-js/chunk-NB3NZRBM.js.map +0 -1
- package/src/testing-js/chunk-SHBYZUPD.js.map +0 -1
- package/src/testing-js/chunk-X6KHEVIG.js.map +0 -1
- /package/src/testing-js/{chunk-WIZ6W7UT.js.map → chunk-3WCLMOM7.js.map} +0 -0
- /package/src/testing-js/{chunk-GKO27NGN.js.map → chunk-R54I7YL6.js.map} +0 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { a11yPropertyFindings } from '../../core/a11y/a11yReport';
|
|
3
|
+
import UIInfoPopover from '../UIInfoPopover.svelte';
|
|
4
|
+
import A11yFindingRow from './A11yFindingRow.svelte';
|
|
5
|
+
import { accessibilityIssuesLabel, findingRows, reviewLabel } from './a11yView';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
variables: string[];
|
|
9
|
+
/** `gutter` hangs the mark left of its nearest positioned ancestor, in the gap between a row's label and its control. */
|
|
10
|
+
placement?: 'inline' | 'gutter';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let { variables, placement = 'inline' }: Props = $props();
|
|
14
|
+
|
|
15
|
+
let findings = $derived([...new Set(variables.flatMap((variable) => $a11yPropertyFindings.get(variable) ?? []))]);
|
|
16
|
+
let review = $derived(findings.every((finding) => finding.rule === 'a11y-review'));
|
|
17
|
+
let label = $derived(review ? reviewLabel(findings.length) : accessibilityIssuesLabel(findings.length));
|
|
18
|
+
let rows = $derived(findingRows([
|
|
19
|
+
...findings.filter((finding) => finding.rule !== 'a11y-review'),
|
|
20
|
+
...findings.filter((finding) => finding.rule === 'a11y-review'),
|
|
21
|
+
]));
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
{#if findings.length > 0}
|
|
25
|
+
<span class="a11y-property-mark" class:gutter={placement === 'gutter'}>
|
|
26
|
+
<UIInfoPopover title={label} ariaLabel={label}>
|
|
27
|
+
{#snippet trigger()}
|
|
28
|
+
{#if review}
|
|
29
|
+
<span class="a11y-property-mark-glyph review" aria-hidden="true"></span>
|
|
30
|
+
{:else}
|
|
31
|
+
<i class="fa-solid fa-triangle-exclamation a11y-property-mark-glyph" aria-hidden="true"></i>
|
|
32
|
+
{/if}
|
|
33
|
+
{/snippet}
|
|
34
|
+
<ul class="a11y-property-findings">
|
|
35
|
+
{#each rows as row, i (i)}
|
|
36
|
+
<A11yFindingRow component={row.finding.component ?? ''} {row} review={row.finding.rule === 'a11y-review'} />
|
|
37
|
+
{/each}
|
|
38
|
+
</ul>
|
|
39
|
+
</UIInfoPopover>
|
|
40
|
+
</span>
|
|
41
|
+
{/if}
|
|
42
|
+
|
|
43
|
+
<style>
|
|
44
|
+
.a11y-property-mark {
|
|
45
|
+
display: inline-flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
margin-left: var(--ui-space-4);
|
|
48
|
+
line-height: 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* No transform: it would make this span the containing block of the
|
|
52
|
+
popover, which positions itself against the viewport. */
|
|
53
|
+
.a11y-property-mark.gutter {
|
|
54
|
+
position: absolute;
|
|
55
|
+
top: 0;
|
|
56
|
+
bottom: 0;
|
|
57
|
+
right: 100%;
|
|
58
|
+
margin: 0 var(--ui-space-2) 0 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.a11y-property-mark-glyph {
|
|
62
|
+
color: var(--ui-highlight);
|
|
63
|
+
font-size: var(--ui-font-size-xs);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* The "?" is generated content because the contract harness matches rows by their text. */
|
|
67
|
+
.a11y-property-mark-glyph.review::after {
|
|
68
|
+
content: '?';
|
|
69
|
+
color: var(--ui-text-tertiary);
|
|
70
|
+
font-size: var(--ui-font-size-sm);
|
|
71
|
+
font-weight: var(--ui-font-weight-bold);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.a11y-property-findings {
|
|
75
|
+
margin: 0;
|
|
76
|
+
padding: 0;
|
|
77
|
+
list-style: none;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* The popover spaces its own paragraphs, which would double the row's gaps. */
|
|
81
|
+
.a11y-property-findings :global(.a11y-issue p) {
|
|
82
|
+
margin: 0;
|
|
83
|
+
}
|
|
84
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
/** `corner` overlaps the icon of a condensed rail, where the label and its inline mark are hidden. */
|
|
4
|
+
placement?: 'inline' | 'corner';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
let { placement = 'inline' }: Props = $props();
|
|
8
|
+
const label = 'Accessibility issues';
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
{#if placement === 'inline'}
|
|
12
|
+
<i class="fa-solid fa-triangle-exclamation a11y-rail-mark" role="img" aria-label={label} title={label}></i>
|
|
13
|
+
{:else}
|
|
14
|
+
<i class="fa-solid fa-triangle-exclamation a11y-rail-mark corner" aria-hidden="true"></i>
|
|
15
|
+
{/if}
|
|
16
|
+
|
|
17
|
+
<style>
|
|
18
|
+
.a11y-rail-mark {
|
|
19
|
+
margin-right: var(--ui-space-6);
|
|
20
|
+
color: var(--ui-highlight);
|
|
21
|
+
font-size: var(--ui-font-size-sm);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* Bottom-right of the icon, clear of the unsaved dot at its top-right. */
|
|
25
|
+
.corner {
|
|
26
|
+
position: absolute;
|
|
27
|
+
top: 50%;
|
|
28
|
+
left: 30px;
|
|
29
|
+
margin: 0;
|
|
30
|
+
font-size: var(--ui-font-size-xs);
|
|
31
|
+
text-shadow: 0 0 2px black;
|
|
32
|
+
pointer-events: none;
|
|
33
|
+
}
|
|
34
|
+
</style>
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { a11yFinishedReport, a11yIssues, a11yReport, startA11yRun } from '../../core/a11y/a11yReport';
|
|
3
|
+
import { contrastFailures } from '../../core/a11y/contrastFailures';
|
|
4
|
+
import { allContrastPairs } from '../../core/components/contrastPairRegistry';
|
|
5
|
+
import { createDomResolver } from '../../core/palettes/resolveColor';
|
|
6
|
+
import { unsavedEdits } from '../../core/store/unsavedEdits';
|
|
7
|
+
import { listThemes } from '../../core/themes/themeService';
|
|
8
|
+
import UIPillButton from '../UIPillButton.svelte';
|
|
9
|
+
import A11yComponentIssues from './A11yComponentIssues.svelte';
|
|
10
|
+
import A11yFindingRow from './A11yFindingRow.svelte';
|
|
11
|
+
import A11yOverview from './A11yOverview.svelte';
|
|
12
|
+
import {
|
|
13
|
+
A11Y_REVIEW_SECTION,
|
|
14
|
+
A11Y_RUN_SECTION,
|
|
15
|
+
a11ySectionId,
|
|
16
|
+
codeSpans,
|
|
17
|
+
componentsWithIssues,
|
|
18
|
+
formatElapsed,
|
|
19
|
+
formatRunTime,
|
|
20
|
+
getA11yViewLink,
|
|
21
|
+
issuesLabel,
|
|
22
|
+
openComponentAt,
|
|
23
|
+
reviewLabel,
|
|
24
|
+
reviewResults,
|
|
25
|
+
} from './a11yView';
|
|
26
|
+
|
|
27
|
+
const openView = getA11yViewLink();
|
|
28
|
+
|
|
29
|
+
let rows = $derived(componentsWithIssues($a11yIssues));
|
|
30
|
+
let review = $derived(reviewResults($a11yIssues));
|
|
31
|
+
let reviewTotal = $derived(rows.reduce((sum, row) => sum + row.issues.review.length, 0));
|
|
32
|
+
let running = $derived($a11yReport?.state === 'running' ? $a11yReport : null);
|
|
33
|
+
let failed = $derived($a11yReport?.state === 'failed' ? $a11yReport : null);
|
|
34
|
+
let finished = $derived($a11yFinishedReport);
|
|
35
|
+
let runProblems = $derived(finished?.findings.filter((f) => f.rule.startsWith('tests-')) ?? []);
|
|
36
|
+
let notInstalled = $derived(runProblems.some((f) => f.rule === 'tests-not-installed'));
|
|
37
|
+
|
|
38
|
+
/** Set by a press while edits were pending; the alert shows only while they still are. */
|
|
39
|
+
let blockedByEdits = $state(false);
|
|
40
|
+
let startError: string | null = $state(null);
|
|
41
|
+
let starting = $state(false);
|
|
42
|
+
|
|
43
|
+
let now = $state(Date.now());
|
|
44
|
+
$effect(() => {
|
|
45
|
+
if (!running) return;
|
|
46
|
+
now = Date.now();
|
|
47
|
+
const timer = setInterval(() => (now = Date.now()), 1000);
|
|
48
|
+
return () => clearInterval(timer);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
let themeNames: Record<string, string> = $state({});
|
|
52
|
+
let themeName = $derived(finished?.theme ? (themeNames[finished.theme] ?? finished.theme) : null);
|
|
53
|
+
$effect(() => {
|
|
54
|
+
if (!finished?.theme) return;
|
|
55
|
+
listThemes()
|
|
56
|
+
.then((themes) => (themeNames = Object.fromEntries(themes.map((t) => [t.fileName, t.name]))))
|
|
57
|
+
.catch(() => {});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
async function runTest() {
|
|
61
|
+
startError = null;
|
|
62
|
+
if ($unsavedEdits) {
|
|
63
|
+
blockedByEdits = true;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
blockedByEdits = false;
|
|
67
|
+
starting = true;
|
|
68
|
+
try {
|
|
69
|
+
await startA11yRun(contrastFailures(allContrastPairs(), createDomResolver()));
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const { code, message } = err as Error & { code?: string };
|
|
72
|
+
startError = code === 'A11Y_RUN_IN_PROGRESS' ? 'A test is already running.' : message;
|
|
73
|
+
} finally {
|
|
74
|
+
starting = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
{#snippet message(text: string)}
|
|
80
|
+
{#each codeSpans(text) as part, i (i)}{#if part.code}<code>{part.text}</code>{:else}{part.text}{/if}{/each}
|
|
81
|
+
{/snippet}
|
|
82
|
+
|
|
83
|
+
<div class="a11y-tab">
|
|
84
|
+
<section id={A11Y_RUN_SECTION} class="a11y-run">
|
|
85
|
+
<header class="a11y-head">
|
|
86
|
+
<h2 class="a11y-title">Accessibility</h2>
|
|
87
|
+
<UIPillButton
|
|
88
|
+
variant="primary"
|
|
89
|
+
icon="fa-play"
|
|
90
|
+
disabled={notInstalled || running !== null || starting}
|
|
91
|
+
onclick={runTest}
|
|
92
|
+
>Run test</UIPillButton>
|
|
93
|
+
</header>
|
|
94
|
+
|
|
95
|
+
{#if blockedByEdits && $unsavedEdits}
|
|
96
|
+
<p class="a11y-alert" role="alert">Save the changes you want to have checked.</p>
|
|
97
|
+
{/if}
|
|
98
|
+
{#if startError}
|
|
99
|
+
<p class="a11y-alert" role="alert">{startError}</p>
|
|
100
|
+
{/if}
|
|
101
|
+
|
|
102
|
+
{#if running}
|
|
103
|
+
<p class="a11y-status" role="status">Test running, {formatElapsed(now - Date.parse(running.startedAt))}</p>
|
|
104
|
+
{/if}
|
|
105
|
+
{#if failed}
|
|
106
|
+
<div class="a11y-failed">
|
|
107
|
+
<p class="a11y-status">The run of {formatRunTime(failed.startedAt)} failed.</p>
|
|
108
|
+
<details class="a11y-disclosure">
|
|
109
|
+
<summary>Error</summary>
|
|
110
|
+
<pre class="a11y-error">{failed.error}</pre>
|
|
111
|
+
</details>
|
|
112
|
+
</div>
|
|
113
|
+
{/if}
|
|
114
|
+
|
|
115
|
+
{#if finished}
|
|
116
|
+
<p class="a11y-status">
|
|
117
|
+
Last run {formatRunTime(finished.finishedAt)}{#if themeName}, theme {themeName}{/if}
|
|
118
|
+
</p>
|
|
119
|
+
{#if runProblems.length > 0}
|
|
120
|
+
<ul class="a11y-problems">
|
|
121
|
+
{#each runProblems as problem, i (i)}
|
|
122
|
+
<li>{@render message(problem.message)}</li>
|
|
123
|
+
{/each}
|
|
124
|
+
</ul>
|
|
125
|
+
{#if notInstalled}
|
|
126
|
+
<div class="a11y-recheck">
|
|
127
|
+
<UIPillButton size="compact" variant="outline" icon="fa-rotate-right" onclick={runTest}>Check again</UIPillButton>
|
|
128
|
+
</div>
|
|
129
|
+
{/if}
|
|
130
|
+
{/if}
|
|
131
|
+
{#if !notInstalled && rows.length === 0}
|
|
132
|
+
<p class="a11y-summary">Every component passes the automated checks.</p>
|
|
133
|
+
{/if}
|
|
134
|
+
{:else if !running && !failed}
|
|
135
|
+
<div class="a11y-empty">
|
|
136
|
+
<p>
|
|
137
|
+
Run test checks every component in the open theme as saved. It scans each one with axe and Alfa,
|
|
138
|
+
presses the keys each one answers, and measures the contrast of every color pair.
|
|
139
|
+
</p>
|
|
140
|
+
<p>A run takes about three minutes. The report stays here until the next run.</p>
|
|
141
|
+
</div>
|
|
142
|
+
{/if}
|
|
143
|
+
</section>
|
|
144
|
+
|
|
145
|
+
{#if rows.length > 0}
|
|
146
|
+
<A11yOverview {rows} onjump={(component) => openView?.(a11ySectionId(component))} />
|
|
147
|
+
{/if}
|
|
148
|
+
|
|
149
|
+
{#each rows as row (row.component)}
|
|
150
|
+
<section id={a11ySectionId(row.component)} class="editor-section-card a11y-component">
|
|
151
|
+
<header class="a11y-component-head">
|
|
152
|
+
<i class={row.icon} aria-hidden="true"></i>
|
|
153
|
+
<h3 class="editor-subsection-title">{row.label}</h3>
|
|
154
|
+
<span class="a11y-count">{issuesLabel(row.count)}</span>
|
|
155
|
+
<UIPillButton
|
|
156
|
+
size="compact"
|
|
157
|
+
variant="outline"
|
|
158
|
+
icon="fa-arrow-right"
|
|
159
|
+
title={`Open ${row.label} in the Components view`}
|
|
160
|
+
onclick={() => openComponentAt(row.component)}
|
|
161
|
+
>Open component</UIPillButton>
|
|
162
|
+
</header>
|
|
163
|
+
<A11yComponentIssues
|
|
164
|
+
component={row.component}
|
|
165
|
+
issues={row.issues}
|
|
166
|
+
onreveal={(variable) => openComponentAt(row.component, variable)}
|
|
167
|
+
onopen={() => openComponentAt(row.component)}
|
|
168
|
+
/>
|
|
169
|
+
{#if row.issues.review.length > 0}
|
|
170
|
+
<button type="button" class="a11y-review-link" onclick={() => openView?.(A11Y_REVIEW_SECTION)}>
|
|
171
|
+
{reviewLabel(row.issues.review.length)}
|
|
172
|
+
</button>
|
|
173
|
+
{/if}
|
|
174
|
+
</section>
|
|
175
|
+
{/each}
|
|
176
|
+
|
|
177
|
+
{#if review.length > 0}
|
|
178
|
+
<section id={A11Y_REVIEW_SECTION} class="a11y-review">
|
|
179
|
+
<details class="a11y-disclosure">
|
|
180
|
+
<summary>{reviewLabel(reviewTotal)}</summary>
|
|
181
|
+
<ul class="a11y-review-list">
|
|
182
|
+
{#each review as { component, label, row }, i (i)}
|
|
183
|
+
<A11yFindingRow
|
|
184
|
+
{component}
|
|
185
|
+
{row}
|
|
186
|
+
review
|
|
187
|
+
prefix={label}
|
|
188
|
+
onreveal={(variable) => openComponentAt(component, variable)}
|
|
189
|
+
onopen={() => openComponentAt(component)}
|
|
190
|
+
/>
|
|
191
|
+
{/each}
|
|
192
|
+
</ul>
|
|
193
|
+
</details>
|
|
194
|
+
</section>
|
|
195
|
+
{/if}
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<style>
|
|
199
|
+
.a11y-tab {
|
|
200
|
+
display: flex;
|
|
201
|
+
flex-direction: column;
|
|
202
|
+
gap: var(--ui-space-20);
|
|
203
|
+
font-size: var(--ui-font-size-md);
|
|
204
|
+
color: var(--ui-text-secondary);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.a11y-run {
|
|
208
|
+
display: flex;
|
|
209
|
+
flex-direction: column;
|
|
210
|
+
gap: var(--ui-space-8);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.a11y-head {
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
justify-content: space-between;
|
|
217
|
+
gap: var(--ui-space-12);
|
|
218
|
+
/* Clears an overlay scrollbar and lines the button up with the cards' own buttons. */
|
|
219
|
+
padding-right: var(--ui-space-20);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.a11y-title {
|
|
223
|
+
margin: 0;
|
|
224
|
+
font-size: var(--ui-font-size-2xl);
|
|
225
|
+
font-weight: var(--ui-font-weight-semibold);
|
|
226
|
+
color: var(--ui-text-primary);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.a11y-alert,
|
|
230
|
+
.a11y-status,
|
|
231
|
+
.a11y-summary,
|
|
232
|
+
.a11y-empty p {
|
|
233
|
+
margin: 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.a11y-alert {
|
|
237
|
+
color: var(--ui-highlight);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.a11y-summary {
|
|
241
|
+
color: var(--ui-text-primary);
|
|
242
|
+
font-weight: var(--ui-font-weight-semibold);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.a11y-empty {
|
|
246
|
+
display: flex;
|
|
247
|
+
flex-direction: column;
|
|
248
|
+
gap: var(--ui-space-8);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.a11y-problems {
|
|
252
|
+
display: flex;
|
|
253
|
+
flex-direction: column;
|
|
254
|
+
gap: var(--ui-space-4);
|
|
255
|
+
margin: 0;
|
|
256
|
+
padding-left: var(--ui-space-20);
|
|
257
|
+
color: var(--ui-text-primary);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.a11y-recheck {
|
|
261
|
+
display: flex;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.a11y-failed {
|
|
265
|
+
display: flex;
|
|
266
|
+
flex-direction: column;
|
|
267
|
+
gap: var(--ui-space-4);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.a11y-disclosure summary {
|
|
271
|
+
cursor: pointer;
|
|
272
|
+
color: var(--ui-text-primary);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.a11y-error {
|
|
276
|
+
margin: var(--ui-space-8) 0 0;
|
|
277
|
+
padding: var(--ui-space-12);
|
|
278
|
+
overflow-x: auto;
|
|
279
|
+
background: var(--ui-surface-low);
|
|
280
|
+
border: 1px solid var(--ui-border-low);
|
|
281
|
+
border-radius: var(--ui-radius-md);
|
|
282
|
+
font-family: var(--ui-font-mono);
|
|
283
|
+
font-size: var(--ui-font-size-xs);
|
|
284
|
+
white-space: pre-wrap;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.a11y-component {
|
|
288
|
+
gap: var(--ui-space-12);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.a11y-component-head {
|
|
292
|
+
display: flex;
|
|
293
|
+
flex-wrap: wrap;
|
|
294
|
+
align-items: center;
|
|
295
|
+
gap: var(--ui-space-8) var(--ui-space-12);
|
|
296
|
+
color: var(--ui-text-primary);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.a11y-count {
|
|
300
|
+
font-size: var(--ui-font-size-sm);
|
|
301
|
+
color: var(--ui-text-tertiary);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.a11y-component-head :global(.ui-pill) {
|
|
305
|
+
margin-left: auto;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.a11y-review-link {
|
|
309
|
+
align-self: flex-start;
|
|
310
|
+
padding: 0;
|
|
311
|
+
background: none;
|
|
312
|
+
border: none;
|
|
313
|
+
font-size: var(--ui-font-size-sm);
|
|
314
|
+
color: var(--ui-text-tertiary);
|
|
315
|
+
text-decoration: underline;
|
|
316
|
+
text-underline-offset: 2px;
|
|
317
|
+
cursor: pointer;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.a11y-review-link:hover {
|
|
321
|
+
color: var(--ui-text-primary);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.a11y-review-list {
|
|
325
|
+
margin: var(--ui-space-8) 0 0;
|
|
326
|
+
padding: 0;
|
|
327
|
+
list-style: none;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.a11y-tab code {
|
|
331
|
+
font-family: var(--ui-font-mono);
|
|
332
|
+
font-size: var(--ui-font-size-sm);
|
|
333
|
+
}
|
|
334
|
+
</style>
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import { writable } from 'svelte/store';
|
|
3
|
+
import { getComponentRegistry, getComponentRegistryEntries } from '../../component-editor/registry';
|
|
4
|
+
import { selectedComponent, setEditorView } from '../../core/store/editorViewStore';
|
|
5
|
+
import type { ComponentA11yIssues } from '../../core/a11y/a11yReport';
|
|
6
|
+
import type { A11yFinding } from '../../core/a11y/a11yReportTypes';
|
|
7
|
+
|
|
8
|
+
export const A11Y_RUN_SECTION = 'a11y-run';
|
|
9
|
+
export const A11Y_REVIEW_SECTION = 'a11y-review';
|
|
10
|
+
|
|
11
|
+
export function a11ySectionId(component: string): string {
|
|
12
|
+
return `a11y-component-${component}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const VIEW_LINK = Symbol('a11y-view-link');
|
|
16
|
+
|
|
17
|
+
/** Set by the editor shell, which hosts the Accessibility view. The standalone component page hosts none. */
|
|
18
|
+
export function setA11yViewLink(open: (sectionId: string) => void): void {
|
|
19
|
+
setContext(VIEW_LINK, open);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getA11yViewLink(): ((sectionId: string) => void) | undefined {
|
|
23
|
+
return getContext(VIEW_LINK);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface A11yRevealTarget {
|
|
27
|
+
component: string;
|
|
28
|
+
/** The property row to scroll to. Null scrolls to the component's issues panel. */
|
|
29
|
+
variable: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One-shot: where the component page scrolls once it opens. The issues panel of that component clears it. */
|
|
33
|
+
export const pendingA11yReveal = writable<A11yRevealTarget | null>(null);
|
|
34
|
+
|
|
35
|
+
export function openComponentAt(component: string, variable: string | null = null): void {
|
|
36
|
+
pendingA11yReveal.set({ component, variable });
|
|
37
|
+
selectedComponent.set(component);
|
|
38
|
+
setEditorView('components');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function componentLabel(component: string): string {
|
|
42
|
+
return getComponentRegistry()[component]?.label ?? component;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The success criteria the suite's own checks link to by their Understanding page. */
|
|
46
|
+
const UNDERSTANDING_CRITERIA: Record<string, string> = {
|
|
47
|
+
'non-text-contrast': '1.4.11',
|
|
48
|
+
'text-spacing': '1.4.12',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Names the kind of page a finding's help URL opens, so the link says where it goes.
|
|
52
|
+
* axe and Alfa link to their own rule pages; the suite's own checks link to WCAG and the APG. */
|
|
53
|
+
export function guidanceLabel(url: string): string {
|
|
54
|
+
const { hostname, pathname } = new URL(url);
|
|
55
|
+
if (hostname === 'www.w3.org') {
|
|
56
|
+
const understanding = pathname.match(/\/Understanding\/([a-z-]+)\.html$/);
|
|
57
|
+
if (understanding) {
|
|
58
|
+
const criterion = UNDERSTANDING_CRITERIA[understanding[1]];
|
|
59
|
+
return criterion ? `Success criterion ${criterion}` : 'Success criterion';
|
|
60
|
+
}
|
|
61
|
+
if (pathname.includes('/Techniques/')) return 'Technique';
|
|
62
|
+
if (pathname.includes('/ARIA/apg/')) return 'Pattern';
|
|
63
|
+
}
|
|
64
|
+
if (hostname === 'playwright.dev') return 'Reference';
|
|
65
|
+
return 'Rule';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface A11yIssueTotals {
|
|
69
|
+
property: number;
|
|
70
|
+
code: number;
|
|
71
|
+
review: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A component's findings counted by the fix they need, as the view groups them. */
|
|
75
|
+
export function issueTotals(issues: ComponentA11yIssues): A11yIssueTotals {
|
|
76
|
+
return {
|
|
77
|
+
property: issues.contrast.length + issues.property.length,
|
|
78
|
+
code: issues.code.length,
|
|
79
|
+
review: issues.review.length,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function accessibilityIssuesLabel(count: number): string {
|
|
84
|
+
return `${count} accessibility ${count === 1 ? 'issue' : 'issues'}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function reviewLabel(count: number): string {
|
|
88
|
+
return `${count} ${count === 1 ? 'result' : 'results'} to check by eye`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Every finding of the run counts, results to check by eye included (decision 6). */
|
|
92
|
+
export function issueCount(issues: ComponentA11yIssues): number {
|
|
93
|
+
return issues.contrast.length + issues.property.length + issues.code.length + issues.review.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One panel row: the findings that share an engine rule, a help text and the properties they trace to. */
|
|
97
|
+
export interface A11yFindingRow {
|
|
98
|
+
finding: A11yFinding;
|
|
99
|
+
targets: string[];
|
|
100
|
+
views: string;
|
|
101
|
+
variables: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const VIEW_LIMIT = 4;
|
|
105
|
+
const MORE_VIEWS = /^and \d+ more$/;
|
|
106
|
+
|
|
107
|
+
/** The views of several findings, once each, in the order they first
|
|
108
|
+
* appear. A finding lists four views and counts the rest, so the merged list
|
|
109
|
+
* cannot count what it drops and says "and more". */
|
|
110
|
+
function mergeViews(views: (string | undefined)[]): string {
|
|
111
|
+
const named: string[] = [];
|
|
112
|
+
let hidden = false;
|
|
113
|
+
for (const view of views) {
|
|
114
|
+
for (const part of view ? view.split('; ') : []) {
|
|
115
|
+
if (MORE_VIEWS.test(part)) hidden = true;
|
|
116
|
+
else if (!named.includes(part)) named.push(part);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const shown = named.slice(0, VIEW_LIMIT);
|
|
120
|
+
return hidden || named.length > shown.length ? [...shown, 'and more'].join('; ') : shown.join('; ');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Findings that share an engine rule, a help text and their traced properties merge into one row (decision 6). */
|
|
124
|
+
export function findingRows(findings: A11yFinding[]): A11yFindingRow[] {
|
|
125
|
+
const rows = new Map<string, A11yFinding[]>();
|
|
126
|
+
for (const finding of findings) {
|
|
127
|
+
const key = finding.details
|
|
128
|
+
? [finding.rule, finding.details.engineRule, finding.details.help, ...(finding.details.variables ?? [])].join('\n')
|
|
129
|
+
: finding.message;
|
|
130
|
+
rows.set(key, [...(rows.get(key) ?? []), finding]);
|
|
131
|
+
}
|
|
132
|
+
return [...rows.values()].map((group) => ({
|
|
133
|
+
finding: group[0],
|
|
134
|
+
targets: [...new Set(group.flatMap((f) => (f.details ? [f.details.target] : [])))],
|
|
135
|
+
views: group.length === 1 ? (group[0].details?.view ?? '') : mergeViews(group.map((f) => f.details?.view)),
|
|
136
|
+
variables: group[0].details?.variables ?? [],
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A finding's own words. A keyboard case's engine rule is the case's own name, which the help alone leaves out. */
|
|
141
|
+
export function findingText(finding: A11yFinding): string {
|
|
142
|
+
if (!finding.details) return finding.message;
|
|
143
|
+
return finding.rule === 'a11y-keyboard' ? `${finding.details.engineRule}: ${finding.details.help}` : finding.details.help;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function elementsLabel(count: number): string {
|
|
147
|
+
return `${count} ${count === 1 ? 'element' : 'elements'}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function issuesLabel(count: number): string {
|
|
151
|
+
return `${count} ${count === 1 ? 'issue' : 'issues'}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface A11yComponentRow {
|
|
155
|
+
component: string;
|
|
156
|
+
label: string;
|
|
157
|
+
icon: string;
|
|
158
|
+
count: number;
|
|
159
|
+
issues: ComponentA11yIssues;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Every registered component with a finding, in the component rail's order. */
|
|
163
|
+
export function componentsWithIssues(all: Record<string, ComponentA11yIssues>): A11yComponentRow[] {
|
|
164
|
+
return getComponentRegistryEntries().flatMap(({ id, label, icon }) => {
|
|
165
|
+
const issues = all[id];
|
|
166
|
+
const count = issues ? issueCount(issues) : 0;
|
|
167
|
+
return count > 0 ? [{ component: id, label, icon, count, issues }] : [];
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Every result to check by eye, merged into rows per component, in the rail's order. */
|
|
172
|
+
export function reviewResults(all: Record<string, ComponentA11yIssues>): { component: string; label: string; row: A11yFindingRow }[] {
|
|
173
|
+
return getComponentRegistryEntries().flatMap(({ id, label }) =>
|
|
174
|
+
findingRows(all[id]?.review ?? []).map((row) => ({ component: id, label, row })),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The words of `variable` after the component's prefix that `label` lacks,
|
|
179
|
+
* such as "focused" for `--input-focused-border` and "border color". Rows in
|
|
180
|
+
* different variants and states share a label, so these words tell them apart. */
|
|
181
|
+
function qualifier(component: string, variable: string, label: string): string {
|
|
182
|
+
const prefix = `--${component}-`;
|
|
183
|
+
if (!variable.startsWith(prefix)) return '';
|
|
184
|
+
const own = new Set(label.split(/[\s-]+/));
|
|
185
|
+
return variable.slice(prefix.length).split('-').filter((word) => !own.has(word)).join(' ');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The label of the property row that sets `variable`, led by its variant and
|
|
189
|
+
* state words, with its first letter raised. An intrinsic has no schema row,
|
|
190
|
+
* so its key names it. */
|
|
191
|
+
export function propertyLabel(component: string, variable: string): string | null {
|
|
192
|
+
const entry = getComponentRegistry()[component];
|
|
193
|
+
const token = entry?.schema.find((t) => t.variable === variable);
|
|
194
|
+
const label = token
|
|
195
|
+
? [qualifier(component, variable, token.label), token.label].filter(Boolean).join(' ')
|
|
196
|
+
: entry?.intrinsics?.find((spec) => spec.variants.some((v) => spec.variable(v) === variable))?.key.replaceAll('-', ' ');
|
|
197
|
+
return label ? label.charAt(0).toUpperCase() + label.slice(1) : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A message split on its backticks, so a command or a variable renders as code. */
|
|
201
|
+
export function codeSpans(message: string): { text: string; code: boolean }[] {
|
|
202
|
+
return message.split('`').map((text, i) => ({ text, code: i % 2 === 1 })).filter((part) => part.text);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
206
|
+
|
|
207
|
+
/** "14:02" for a time today, "22 Oct 14:02" for any other day. */
|
|
208
|
+
export function formatRunTime(iso: string, now: Date = new Date()): string {
|
|
209
|
+
const at = new Date(iso);
|
|
210
|
+
const clock = `${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
|
211
|
+
if (at.toDateString() === now.toDateString()) return clock;
|
|
212
|
+
return `${at.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })} ${clock}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function formatElapsed(ms: number): string {
|
|
216
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
217
|
+
return `${Math.floor(seconds / 60)}:${pad(seconds % 60)}`;
|
|
218
|
+
}
|