@motion-proto/live-tokens 0.97.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.
Files changed (184) hide show
  1. package/.claude/skills/live-tokens-check-compliance/SKILL.md +5 -0
  2. package/.claude/skills/live-tokens-create-component/SKILL.md +4 -3
  3. package/.claude/skills/live-tokens-create-component/references/contract-tests.md +71 -11
  4. package/CHANGELOG.md +272 -0
  5. package/README.md +2 -2
  6. package/bin/check-component.mjs +8 -0
  7. package/bin/check-page.mjs +6 -0
  8. package/bin/cli.mjs +36 -6
  9. package/bin/contractRunner.mjs +142 -17
  10. package/bin/lib/findings.mjs +20 -4
  11. package/bin/rules/a11y.mjs +53 -0
  12. package/dist-plugin/{chunk-A4TBP4MM.js → chunk-SLICH4IH.js} +2 -2
  13. package/dist-plugin/index.cjs +148 -3
  14. package/dist-plugin/index.js +250 -105
  15. package/dist-plugin/setColors/index.js +1 -1
  16. package/package.json +47 -2
  17. package/src/demo/LabeledSelect.svelte +1 -1
  18. package/src/editor/bootstrap.ts +3 -1
  19. package/src/editor/component-editor/BadgeEditor.svelte +13 -0
  20. package/src/editor/component-editor/ButtonEditor.svelte +33 -2
  21. package/src/editor/component-editor/CalloutEditor.svelte +13 -0
  22. package/src/editor/component-editor/CardEditor.svelte +12 -2
  23. package/src/editor/component-editor/CodeSnippetEditor.svelte +11 -1
  24. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +18 -1
  25. package/src/editor/component-editor/CornerBadgeEditor.svelte +13 -0
  26. package/src/editor/component-editor/DialogEditor.svelte +15 -0
  27. package/src/editor/component-editor/IconButtonEditor.svelte +29 -1
  28. package/src/editor/component-editor/ImageEditor.svelte +20 -9
  29. package/src/editor/component-editor/ImageLightboxEditor.svelte +44 -0
  30. package/src/editor/component-editor/InlineEditActionsEditor.svelte +13 -1
  31. package/src/editor/component-editor/InputEditor.svelte +23 -0
  32. package/src/editor/component-editor/MenuSelectEditor.svelte +27 -1
  33. package/src/editor/component-editor/NotificationEditor.svelte +15 -0
  34. package/src/editor/component-editor/PanelEditor.svelte +1 -1
  35. package/src/editor/component-editor/ProgressBarEditor.svelte +13 -0
  36. package/src/editor/component-editor/RadioButtonEditor.svelte +20 -1
  37. package/src/editor/component-editor/SectionDividerEditor.svelte +41 -13
  38. package/src/editor/component-editor/SegmentedControlEditor.svelte +29 -1
  39. package/src/editor/component-editor/SideNavigationEditor.svelte +40 -1
  40. package/src/editor/component-editor/SlidePagerEditor.svelte +20 -0
  41. package/src/editor/component-editor/SliderEditor.svelte +33 -0
  42. package/src/editor/component-editor/TabBarEditor.svelte +23 -2
  43. package/src/editor/component-editor/TableEditor.svelte +14 -0
  44. package/src/editor/component-editor/ToggleEditor.svelte +17 -0
  45. package/src/editor/component-editor/TooltipEditor.svelte +11 -2
  46. package/src/editor/component-editor/VideoFrameEditor.svelte +8 -0
  47. package/src/editor/component-editor/VideoLightboxEditor.svelte +10 -0
  48. package/src/editor/component-editor/registration.ts +2 -1
  49. package/src/editor/component-editor/registry.ts +69 -27
  50. package/src/editor/component-editor/scaffolding/DemoHeader.svelte +2 -0
  51. package/src/editor/component-editor/scaffolding/StateBlock.svelte +19 -8
  52. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +3 -0
  53. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +70 -3
  54. package/src/editor/component-editor/scaffolding/editorContext.ts +7 -0
  55. package/src/editor/core/a11y/a11yReport.ts +101 -0
  56. package/src/editor/core/a11y/a11yReportTypes.ts +69 -0
  57. package/src/editor/core/a11y/contrastFailures.ts +22 -0
  58. package/src/editor/core/components/contrastPairRegistry.ts +18 -0
  59. package/src/editor/core/palettes/contrast.ts +44 -0
  60. package/src/editor/core/palettes/contrastPairs.ts +79 -0
  61. package/src/editor/core/palettes/resolveColor.ts +77 -0
  62. package/src/editor/core/store/editorStore.ts +4 -0
  63. package/src/editor/core/store/editorViewStore.ts +2 -2
  64. package/src/editor/core/store/unsavedEdits.ts +10 -0
  65. package/src/editor/core/themes/liveStateStream.ts +7 -1
  66. package/src/editor/core/themes/parsers/gradient.ts +5 -2
  67. package/src/editor/docs/chapters.ts +1 -0
  68. package/src/editor/docs/content/accessibility.md +362 -0
  69. package/src/editor/docs/content/editing-tokens.md +28 -1
  70. package/src/editor/docs/content.generated.ts +2 -1
  71. package/src/editor/pages/ComponentEditorPage.svelte +10 -1
  72. package/src/editor/pages/EditorShell.svelte +96 -10
  73. package/src/editor/skill-atlas/skillSources.generated.ts +3 -3
  74. package/src/editor/skill-atlas/trees/check-compliance.ts +3 -3
  75. package/src/editor/skill-atlas/trees/create-component.ts +12 -12
  76. package/src/editor/ui/EditorViewSwitcher.svelte +15 -2
  77. package/src/editor/ui/GradientEditor.svelte +9 -1
  78. package/src/editor/ui/ThemePanel.svelte +4 -4
  79. package/src/editor/ui/UIContrastReadout.svelte +186 -0
  80. package/src/editor/ui/UIInfoPopover.svelte +16 -2
  81. package/src/editor/ui/UIPaletteSelector.svelte +9 -0
  82. package/src/editor/ui/UITokenSelector.svelte +26 -2
  83. package/src/editor/ui/a11y/A11yComponentIssues.svelte +103 -0
  84. package/src/editor/ui/a11y/A11yFindingRow.svelte +57 -0
  85. package/src/editor/ui/a11y/A11yIssueRow.svelte +121 -0
  86. package/src/editor/ui/a11y/A11yOverview.svelte +242 -0
  87. package/src/editor/ui/a11y/A11yPanel.svelte +128 -0
  88. package/src/editor/ui/a11y/A11yPropertyMark.svelte +84 -0
  89. package/src/editor/ui/a11y/A11yRailMark.svelte +34 -0
  90. package/src/editor/ui/a11y/AccessibilityTab.svelte +334 -0
  91. package/src/editor/ui/a11y/a11yView.ts +218 -0
  92. package/src/editor/ui/contrastReadout.ts +45 -0
  93. package/src/live-tokens/data/themes/autumn.json +20 -20
  94. package/src/live-tokens/data/themes/halloween.json +20 -20
  95. package/src/live-tokens/data/themes/midnight-study.json +20 -20
  96. package/src/live-tokens/data/themes/ocean.json +20 -20
  97. package/src/live-tokens/data/themes/royal-velvet.json +20 -20
  98. package/src/live-tokens/data/themes/sketchy.json +20 -20
  99. package/src/live-tokens/data/themes/spring-meadow.json +20 -20
  100. package/src/live-tokens/data/themes/sunset.json +20 -20
  101. package/src/system/components/Badge.svelte +3 -3
  102. package/src/system/components/Button.svelte +9 -3
  103. package/src/system/components/CodeSnippet.svelte +8 -1
  104. package/src/system/components/CornerBadge.svelte +3 -3
  105. package/src/system/components/Dialog.svelte +43 -14
  106. package/src/system/components/IconButton.svelte +1 -1
  107. package/src/system/components/Image.svelte +7 -0
  108. package/src/system/components/ImageLightbox.svelte +18 -25
  109. package/src/system/components/Input.svelte +8 -1
  110. package/src/system/components/MenuSelect.svelte +30 -2
  111. package/src/system/components/Notification.svelte +1 -1
  112. package/src/system/components/ProgressBar.svelte +6 -0
  113. package/src/system/components/RadioButton.svelte +36 -3
  114. package/src/system/components/SegmentedControl.svelte +36 -2
  115. package/src/system/components/SlidePager.svelte +2 -2
  116. package/src/system/components/Slider.svelte +13 -9
  117. package/src/system/components/TabBar.svelte +33 -3
  118. package/src/system/components/Toggle.svelte +2 -2
  119. package/src/system/components/VideoFrame.svelte +1 -1
  120. package/src/system/components/VideoLightbox.svelte +2 -2
  121. package/src/system/internal/focusTrap.ts +35 -0
  122. package/src/testing-js/__aria__/badge.yml +40 -0
  123. package/src/testing-js/__aria__/button.yml +88 -0
  124. package/src/testing-js/__aria__/callout.yml +12 -0
  125. package/src/testing-js/__aria__/card.yml +8 -0
  126. package/src/testing-js/__aria__/codesnippet.yml +21 -0
  127. package/src/testing-js/__aria__/collapsiblesection.yml +23 -0
  128. package/src/testing-js/__aria__/cornerbadge.yml +24 -0
  129. package/src/testing-js/__aria__/dialog.yml +42 -0
  130. package/src/testing-js/__aria__/iconbutton.yml +57 -0
  131. package/src/testing-js/__aria__/image.yml +10 -0
  132. package/src/testing-js/__aria__/imagelightbox.yml +50 -0
  133. package/src/testing-js/__aria__/inlineeditactions.yml +16 -0
  134. package/src/testing-js/__aria__/input.yml +61 -0
  135. package/src/testing-js/__aria__/menuselect.yml +30 -0
  136. package/src/testing-js/__aria__/notification.yml +30 -0
  137. package/src/testing-js/__aria__/panel.yml +2 -0
  138. package/src/testing-js/__aria__/progressbar.yml +2 -0
  139. package/src/testing-js/__aria__/radiobutton.yml +20 -0
  140. package/src/testing-js/__aria__/sectiondivider.yml +9 -0
  141. package/src/testing-js/__aria__/segmentedcontrol.yml +35 -0
  142. package/src/testing-js/__aria__/sidenavigation.yml +304 -0
  143. package/src/testing-js/__aria__/slidepager.yml +72 -0
  144. package/src/testing-js/__aria__/slider.yml +28 -0
  145. package/src/testing-js/__aria__/tabbar.yml +35 -0
  146. package/src/testing-js/__aria__/table.yml +144 -0
  147. package/src/testing-js/__aria__/toggle.yml +12 -0
  148. package/src/testing-js/__aria__/tooltip.yml +2 -0
  149. package/src/testing-js/__aria__/videoframe.yml +6 -0
  150. package/src/testing-js/__aria__/videolightbox.yml +12 -0
  151. package/src/testing-js/{chunk-WIZ6W7UT.js → chunk-3WCLMOM7.js} +2 -2
  152. package/src/testing-js/{chunk-SHBYZUPD.js → chunk-5HNZHIHG.js} +211 -14
  153. package/src/testing-js/chunk-5HNZHIHG.js.map +1 -0
  154. package/src/testing-js/{chunk-NB3NZRBM.js → chunk-7CM5D7PF.js} +5 -1
  155. package/src/testing-js/chunk-7CM5D7PF.js.map +1 -0
  156. package/src/testing-js/chunk-EWSGE27V.js +168 -0
  157. package/src/testing-js/chunk-EWSGE27V.js.map +1 -0
  158. package/src/testing-js/{chunk-GKO27NGN.js → chunk-R54I7YL6.js} +2 -2
  159. package/src/testing-js/{chunk-X6KHEVIG.js → chunk-RWH46IU5.js} +97 -29
  160. package/src/testing-js/chunk-RWH46IU5.js.map +1 -0
  161. package/src/testing-js/chunk-SHLPDNAQ.js +658 -0
  162. package/src/testing-js/chunk-SHLPDNAQ.js.map +1 -0
  163. package/src/testing-js/{chunk-3UKGXCDL.js → chunk-YVVR3GJZ.js} +1 -1
  164. package/src/testing-js/chunk-YVVR3GJZ.js.map +1 -0
  165. package/src/testing-js/component-a11y.contract.js +283 -0
  166. package/src/testing-js/component-a11y.contract.js.map +1 -0
  167. package/src/testing-js/component-alias.contract.js +2 -2
  168. package/src/testing-js/component-behavior.contract.js +2 -2
  169. package/src/testing-js/component-editor.contract.js +6 -5
  170. package/src/testing-js/component-editor.contract.js.map +1 -1
  171. package/src/testing-js/component-render.contract.js +10 -6
  172. package/src/testing-js/component-render.contract.js.map +1 -1
  173. package/src/testing-js/index.d.ts +44 -16
  174. package/src/testing-js/index.js +9 -9
  175. package/src/testing-js/page-compliance.contract.js +132 -13
  176. package/src/testing-js/page-compliance.contract.js.map +1 -1
  177. package/src/testing-js/vitest.js +3 -3
  178. package/template/package.json +9 -0
  179. package/src/testing-js/chunk-3UKGXCDL.js.map +0 -1
  180. package/src/testing-js/chunk-NB3NZRBM.js.map +0 -1
  181. package/src/testing-js/chunk-SHBYZUPD.js.map +0 -1
  182. package/src/testing-js/chunk-X6KHEVIG.js.map +0 -1
  183. /package/src/testing-js/{chunk-WIZ6W7UT.js.map → chunk-3WCLMOM7.js.map} +0 -0
  184. /package/src/testing-js/{chunk-GKO27NGN.js.map → chunk-R54I7YL6.js.map} +0 -0
@@ -18,6 +18,7 @@
18
18
  import RadialShapePad from '../component-editor/scaffolding/RadialShapePad.svelte';
19
19
  import UISegmentedControl from './UISegmentedControl.svelte';
20
20
  import UIPillButton from './UIPillButton.svelte';
21
+ import A11yPropertyMark from './a11y/A11yPropertyMark.svelte';
21
22
  import { snapTokenToFamily } from '../core/palettes/familySwap';
22
23
 
23
24
  interface Props {
@@ -287,7 +288,10 @@
287
288
  >
288
289
  {#if sectionLabel || hasAside}
289
290
  <div class="editor-header editor-section-left">
290
- <span class="editor-section-label">{sectionLabel ?? ''}</span>
291
+ <span class="editor-section-heading">
292
+ <span class="editor-section-label">{sectionLabel ?? ''}</span>
293
+ <A11yPropertyMark variables={[gradientSource.targetVariable]} />
294
+ </span>
291
295
  {#if !isFlat}
292
296
  <div class="stop-actions">
293
297
  <UIPillButton
@@ -496,6 +500,10 @@
496
500
  color: var(--ui-text-primary);
497
501
  line-height: 1;
498
502
  }
503
+ .editor-section-heading {
504
+ display: inline-flex;
505
+ align-items: center;
506
+ }
499
507
  .editor-section-left { grid-column: 1; }
500
508
  .editor-section-right { grid-column: 2; }
501
509
 
@@ -57,6 +57,7 @@
57
57
  markComponentSaved,
58
58
  } from '../core/store/editorStore';
59
59
  import { openThemeSlug } from '../core/store/editorConfigStore';
60
+ import { unsavedEdits } from '../core/store/unsavedEdits';
60
61
  import { sketchOffTheme, themeSketchSettings } from '../core/sketch/sketchStore';
61
62
  import {
62
63
  componentActiveRevision,
@@ -92,7 +93,6 @@
92
93
  let dirtyComponentCount = $derived(
93
94
  Object.values($componentDirty).filter(Boolean).length,
94
95
  );
95
- let unsavedEdits = $derived($colorsAndTypeDirty || dirtyComponentCount > 0 || $sketchOffTheme);
96
96
 
97
97
  async function refreshFiles() {
98
98
  try {
@@ -282,7 +282,7 @@
282
282
  themeProductionState({
283
283
  openTheme: $openThemeSlug,
284
284
  production: $productionTheme,
285
- unsaved: unsavedEdits || $liveUnsaved,
285
+ unsaved: $unsavedEdits || $liveUnsaved,
286
286
  }),
287
287
  );
288
288
 
@@ -421,7 +421,7 @@
421
421
  async function commitWholeTheme() {
422
422
  const row = previewRow;
423
423
  if (!row) return;
424
- if (unsavedEdits) {
424
+ if ($unsavedEdits) {
425
425
  const ok = window.confirm(
426
426
  'Loading a theme will replace the editor’s current state and discard unsaved changes. Continue?',
427
427
  );
@@ -490,7 +490,7 @@
490
490
  const picked = previewLayer ?? previewedTheme?.colorsAndType ?? null;
491
491
  if (!row || !picked) return;
492
492
  const { _fileName, _source, ...buffer } = picked;
493
- if (unsavedEdits) {
493
+ if ($unsavedEdits) {
494
494
  const ok = window.confirm(
495
495
  'Loading colors and type will discard unsaved changes. Continue?',
496
496
  );
@@ -0,0 +1,186 @@
1
+ <script module lang="ts">
2
+ import { createSubscriber } from 'svelte/reactivity';
3
+ import { on } from 'svelte/events';
4
+ import { THEME_APPLIED_EVENT } from '../core/themes/themeDocumentSync';
5
+
6
+ const themeApplied = createSubscriber((update) => on(document, THEME_APPLIED_EVENT, update));
7
+ </script>
8
+
9
+ <script lang="ts">
10
+ import { editorState } from '../core/store/editorStore';
11
+ import { getContrastPairs } from '../core/components/contrastPairRegistry';
12
+ import { CONTRAST_RULES } from '../core/palettes/contrast';
13
+ import { measurePair, type PairMeasure } from '../core/palettes/contrastPairs';
14
+ import { createDomResolver } from '../core/palettes/resolveColor';
15
+ import { formatRatio, measureState, readoutState, ruleSentence } from './contrastReadout';
16
+ import UIInfoPopover from './UIInfoPopover.svelte';
17
+
18
+ interface Props {
19
+ component: string;
20
+ variable: string;
21
+ }
22
+
23
+ let { component, variable }: Props = $props();
24
+
25
+ let pairs = $derived(getContrastPairs(component, variable));
26
+ let measures = $state<PairMeasure[]>([]);
27
+ let readout = $derived(measures.length > 0 ? readoutState(measures) : null);
28
+
29
+ // Measuring reads the DOM, so it waits a frame. The store renderer writes
30
+ // CSS variables synchronously, so by then the probe reads what the store holds.
31
+ $effect(() => {
32
+ void $editorState;
33
+ themeApplied();
34
+ const current = pairs;
35
+ const frame = requestAnimationFrame(() => {
36
+ const resolver = createDomResolver();
37
+ measures = current.map((pair) => measurePair(pair, resolver));
38
+ });
39
+ return () => cancelAnimationFrame(frame);
40
+ });
41
+ </script>
42
+
43
+ {#if readout}
44
+ <span
45
+ class="contrast-readout"
46
+ data-contrast-state={readout.state}
47
+ data-contrast-ratio={readout.measure.ratio}
48
+ >
49
+ <UIInfoPopover title="Contrast" ariaLabel={readout.ariaLabel}>
50
+ {#snippet trigger()}
51
+ <span class="contrast-value {readout.state}">
52
+ {#if readout.state === 'fail'}
53
+ <i class="fa-solid fa-triangle-exclamation contrast-mark" aria-hidden="true"></i>
54
+ {/if}
55
+ {readout.label}
56
+ </span>
57
+ {/snippet}
58
+ <ul class="contrast-pairs">
59
+ {#each measures as m (m.pair)}
60
+ {@const rule = CONTRAST_RULES[m.pair.role]}
61
+ {@const pairState = measureState(m)}
62
+ <li class="contrast-pair">
63
+ {#if m.pair.context}
64
+ <p class="contrast-context">{m.pair.context}</p>
65
+ {/if}
66
+ <p>{ruleSentence(m)}</p>
67
+ <p class="contrast-measured">
68
+ Measured
69
+ <span class="contrast-value {pairState}">
70
+ {#if pairState === 'fail'}
71
+ <i class="fa-solid fa-triangle-exclamation contrast-mark" aria-hidden="true"></i>
72
+ {/if}
73
+ {formatRatio(m.ratio)}
74
+ </span>
75
+ </p>
76
+ <ol class="contrast-stack" aria-label="Layers behind the color, top first">
77
+ {#each m.pair.backdrop as layer, i (i)}
78
+ <li><code>{layer}</code></li>
79
+ {/each}
80
+ <li>page background</li>
81
+ </ol>
82
+ <a class="contrast-link" href={rule.url} target="_blank" rel="noopener">
83
+ Success criterion {rule.criterion}
84
+ </a>
85
+ </li>
86
+ {/each}
87
+ </ul>
88
+ </UIInfoPopover>
89
+ </span>
90
+ {/if}
91
+
92
+ <style>
93
+ .contrast-readout {
94
+ display: inline-flex;
95
+ flex: 0 0 auto;
96
+ align-items: center;
97
+ }
98
+
99
+ .contrast-value {
100
+ display: inline-flex;
101
+ align-items: baseline;
102
+ gap: var(--ui-space-2);
103
+ font-family: var(--ui-font-mono);
104
+ font-size: var(--ui-font-size-sm);
105
+ white-space: nowrap;
106
+ }
107
+
108
+ .contrast-value.pass {
109
+ color: var(--ui-text-secondary);
110
+ }
111
+
112
+ .contrast-value.fail {
113
+ color: var(--ui-highlight);
114
+ }
115
+
116
+ .contrast-value.inactive {
117
+ color: var(--ui-text-tertiary);
118
+ }
119
+
120
+ .contrast-mark {
121
+ margin-right: var(--ui-space-4);
122
+ font-size: var(--ui-font-size-xs);
123
+ }
124
+
125
+ .contrast-pairs {
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: var(--ui-space-12);
129
+ margin: 0;
130
+ padding: 0;
131
+ list-style: none;
132
+ }
133
+
134
+ .contrast-pair {
135
+ display: flex;
136
+ flex-direction: column;
137
+ gap: var(--ui-space-6);
138
+ }
139
+
140
+ .contrast-pair + .contrast-pair {
141
+ padding-top: var(--ui-space-12);
142
+ border-top: 1px solid var(--ui-border-low);
143
+ }
144
+
145
+ /* The popover's paragraph margin would double the flex gap. */
146
+ .contrast-pair p {
147
+ margin: 0;
148
+ }
149
+
150
+ .contrast-context {
151
+ color: var(--ui-text-primary);
152
+ font-weight: var(--ui-font-weight-semibold);
153
+ }
154
+
155
+ .contrast-measured {
156
+ display: flex;
157
+ align-items: baseline;
158
+ gap: var(--ui-space-6);
159
+ }
160
+
161
+ .contrast-measured .contrast-value {
162
+ font-size: inherit;
163
+ }
164
+
165
+ .contrast-stack {
166
+ display: flex;
167
+ flex-direction: column;
168
+ gap: var(--ui-space-2);
169
+ margin: 0;
170
+ padding: 0;
171
+ list-style: none;
172
+ font-size: var(--ui-font-size-xs);
173
+ color: var(--ui-text-tertiary);
174
+ }
175
+
176
+ .contrast-stack code {
177
+ overflow-wrap: anywhere;
178
+ }
179
+
180
+ .contrast-link {
181
+ align-self: flex-start;
182
+ font-size: var(--ui-font-size-xs);
183
+ color: var(--ui-text-primary);
184
+ text-decoration: underline;
185
+ }
186
+ </style>
@@ -5,10 +5,12 @@
5
5
  interface Props {
6
6
  title: string;
7
7
  ariaLabel?: string;
8
+ /** Replaces the info icon inside the button. */
9
+ trigger?: Snippet;
8
10
  children?: Snippet;
9
11
  }
10
12
 
11
- let { title, ariaLabel, children }: Props = $props();
13
+ let { title, ariaLabel, trigger, children }: Props = $props();
12
14
 
13
15
  let open = $state(false);
14
16
  let btnEl = $state<HTMLButtonElement | undefined>(undefined);
@@ -83,13 +85,18 @@
83
85
  <button
84
86
  type="button"
85
87
  class="info-btn"
88
+ class:has-trigger={!!trigger}
86
89
  aria-label={ariaLabel ?? `About ${title}`}
87
90
  aria-expanded={open}
88
91
  bind:this={btnEl}
89
92
  data-info-popover-root
90
93
  onclick={() => (open = !open)}
91
94
  >
92
- <i class="fas fa-circle-info"></i>
95
+ {#if trigger}
96
+ {@render trigger()}
97
+ {:else}
98
+ <i class="fas fa-circle-info"></i>
99
+ {/if}
93
100
  </button>
94
101
 
95
102
  {#if open}
@@ -140,6 +147,13 @@
140
147
  color: var(--ui-text-primary);
141
148
  }
142
149
 
150
+ .info-btn.has-trigger {
151
+ width: auto;
152
+ height: auto;
153
+ color: inherit;
154
+ font: inherit;
155
+ }
156
+
143
157
  /* Fixed positioning escapes any parent overflow/stacking context. JS
144
158
  anchors it to the right of the trigger; falls back to centered-below
145
159
  then flips up near the viewport bottom. */
@@ -25,6 +25,8 @@
25
25
  import UITokenSelector from './UITokenSelector.svelte';
26
26
  import ColorEditPanel from './ColorEditPanel.svelte';
27
27
  import CustomColorReplaceStep from './CustomColorReplaceStep.svelte';
28
+ import UIContrastReadout from './UIContrastReadout.svelte';
29
+ import { getContrastPairs } from '../core/components/contrastPairRegistry';
28
30
 
29
31
  /** Honor prefers-reduced-motion: `t()` zeroes durations when the OS asks for
30
32
  * less motion, so the detail-row enter/exit slides skip outright. Mirrors
@@ -89,6 +91,8 @@
89
91
  replacing = null,
90
92
  }: Props = $props();
91
93
 
94
+ let hasContrastPairs = $derived(!!component && getContrastPairs(component, variable).length > 0);
95
+
92
96
  const clampOpacity = (n: number) => Math.max(minOpacity, Math.min(100, Math.round(n)));
93
97
  let noneAllowed = $derived(showNone && minOpacity <= 0);
94
98
 
@@ -740,6 +744,10 @@
740
744
  });
741
745
  </script>
742
746
 
747
+ {#snippet contrastReadout()}
748
+ {#if component}<UIContrastReadout {component} {variable} />{/if}
749
+ {/snippet}
750
+
743
751
  <UITokenSelector
744
752
  bind:this={selector}
745
753
  {variable}
@@ -748,6 +756,7 @@
748
756
  {disabled}
749
757
  {selectionsLocked}
750
758
  {onwrite}
759
+ metaAfter={hasContrastPairs ? contrastReadout : undefined}
751
760
  dropdownMinWidth={session ? '22rem' : '14rem'}
752
761
  dropdownMaxWidth="calc(100vw - 2rem)"
753
762
  hideDefaultHeader={!!selectedFamily}
@@ -23,6 +23,7 @@
23
23
  import UILinkToggle from './UILinkToggle.svelte';
24
24
  import UIRelinkConfirmDialog from './UIRelinkConfirmDialog.svelte';
25
25
  import { anchorToTrigger } from './anchorToTrigger';
26
+ import A11yPropertyMark from './a11y/A11yPropertyMark.svelte';
26
27
 
27
28
  type DropdownContext = { close: () => void; handleReset: () => void };
28
29
 
@@ -53,6 +54,8 @@
53
54
  triggerTitle?: Snippet;
54
55
  /** Optional meta text rendered after the trigger. Renamed from `slot="trigger-meta"` in 0.5.0. */
55
56
  triggerMeta?: Snippet;
57
+ /** Optional content after the meta text, in the same column. The meta text truncates first. */
58
+ metaAfter?: Snippet;
56
59
  /** Optional dropdown header replacement. */
57
60
  header?: Snippet;
58
61
  /** Optional dropdown content above the selections. */
@@ -87,6 +90,7 @@
87
90
  triggerText,
88
91
  triggerTitle,
89
92
  triggerMeta,
93
+ metaAfter,
90
94
  header,
91
95
  subheader,
92
96
  children,
@@ -292,6 +296,7 @@
292
296
  bind:this={container}
293
297
  >
294
298
  <div class="ui-ts-trigger-wrap">
299
+ <A11yPropertyMark variables={[variable]} placement="gutter" />
295
300
  <button
296
301
  bind:this={triggerEl}
297
302
  class="ui-ts-trigger"
@@ -362,11 +367,20 @@
362
367
  {/if}
363
368
  </div>
364
369
 
365
- {#if triggerMeta}
370
+ {#snippet metaText(meta: Snippet)}
366
371
  <span
367
372
  class="ui-ts-meta-text"
368
373
  use:mirrorTextToTitle
369
- >{@render triggerMeta()}</span>
374
+ >{@render meta()}</span>
375
+ {/snippet}
376
+
377
+ {#if metaAfter}
378
+ <div class="ui-ts-meta">
379
+ {#if triggerMeta}{@render metaText(triggerMeta)}{/if}
380
+ {@render metaAfter()}
381
+ </div>
382
+ {:else if triggerMeta}
383
+ {@render metaText(triggerMeta)}
370
384
  {/if}
371
385
  </div>
372
386
 
@@ -518,6 +532,16 @@
518
532
  min-width: 0;
519
533
  }
520
534
 
535
+ /* Column 3 when `metaAfter` is set. `min-width: 0` keeps the pair from
536
+ widening the column; the meta text gives up width first. */
537
+ .ui-ts-meta {
538
+ display: flex;
539
+ align-items: center;
540
+ gap: var(--ui-space-6);
541
+ align-self: center;
542
+ min-width: 0;
543
+ }
544
+
521
545
  .ui-ts-chevron {
522
546
  font-size: 0.625rem;
523
547
  color: var(--ui-text-secondary);
@@ -0,0 +1,103 @@
1
+ <script lang="ts">
2
+ import type { ComponentA11yIssues } from '../../core/a11y/a11yReport';
3
+ import { CONTRAST_RULES } from '../../core/palettes/contrast';
4
+ import { formatFloor, formatRatio } from '../contrastReadout';
5
+ import A11yFindingRow from './A11yFindingRow.svelte';
6
+ import A11yIssueRow from './A11yIssueRow.svelte';
7
+ import { findingRows, propertyLabel } from './a11yView';
8
+
9
+ interface Props {
10
+ component: string;
11
+ issues: ComponentA11yIssues;
12
+ /** Lists the results to check by eye as a third group. The Accessibility view lists them apart. */
13
+ withReview?: boolean;
14
+ onreveal?: (variable: string) => void;
15
+ /** Opens the component, for a row that traced no property. Absent on the component's own page. */
16
+ onopen?: () => void;
17
+ }
18
+
19
+ let { component, issues, withReview = false, onreveal, onopen }: Props = $props();
20
+
21
+ let propertyRows = $derived(findingRows(issues.property));
22
+ let codeRows = $derived(findingRows(issues.code));
23
+ let reviewRows = $derived(withReview ? findingRows(issues.review) : []);
24
+ </script>
25
+
26
+ {#if issues.contrast.length > 0 || propertyRows.length > 0}
27
+ <div class="a11y-group">
28
+ <h4 class="a11y-group-title">Fix with a property</h4>
29
+ <ul class="a11y-issues">
30
+ {#each issues.contrast as failure, i (i)}
31
+ {@const label = propertyLabel(component, failure.variable) ?? failure.variable}
32
+ {@const rule = CONTRAST_RULES[failure.role]}
33
+ <A11yIssueRow href={rule.url} linkLabel={`Success criterion ${rule.criterion}`}>
34
+ {label} {formatRatio(failure.ratio)}, needs {formatFloor(failure.floor)}
35
+ {#snippet detail()}
36
+ <code>{failure.variable}</code>{#if failure.context}, {failure.context}{/if}
37
+ {/snippet}
38
+ {#snippet links()}
39
+ {#if onreveal}
40
+ <button type="button" class="a11y-link" aria-label={`Go to ${label}`} title={failure.variable} onclick={() => onreveal(failure.variable)}>
41
+ {label} <i class="fas fa-arrow-right" aria-hidden="true"></i>
42
+ </button>
43
+ {/if}
44
+ {/snippet}
45
+ </A11yIssueRow>
46
+ {/each}
47
+ {#each propertyRows as row, i (i)}
48
+ <A11yFindingRow {component} {row} {onreveal} {onopen} />
49
+ {/each}
50
+ </ul>
51
+ </div>
52
+ {/if}
53
+
54
+ {#if codeRows.length > 0}
55
+ <div class="a11y-group">
56
+ <h4 class="a11y-group-title">Fix in the component's code</h4>
57
+ <ul class="a11y-issues">
58
+ {#each codeRows as row, i (i)}
59
+ <A11yFindingRow {component} {row} {onreveal} {onopen} />
60
+ {/each}
61
+ </ul>
62
+ </div>
63
+ {/if}
64
+
65
+ {#if reviewRows.length > 0}
66
+ <div class="a11y-group">
67
+ <h4 class="a11y-group-title">Check by eye</h4>
68
+ <ul class="a11y-issues">
69
+ {#each reviewRows as row, i (i)}
70
+ <A11yFindingRow {component} {row} review {onreveal} {onopen} />
71
+ {/each}
72
+ </ul>
73
+ </div>
74
+ {/if}
75
+
76
+ <style>
77
+ .a11y-group {
78
+ display: flex;
79
+ flex-direction: column;
80
+ gap: var(--ui-space-4);
81
+ }
82
+
83
+ .a11y-group + .a11y-group {
84
+ margin-top: var(--ui-space-12);
85
+ }
86
+
87
+ .a11y-group-title {
88
+ margin: 0;
89
+ font-size: var(--ui-font-size-xs);
90
+ font-weight: var(--ui-font-weight-semibold);
91
+ color: var(--ui-text-secondary);
92
+ }
93
+
94
+ .a11y-group code {
95
+ font-family: var(--ui-font-mono);
96
+ }
97
+
98
+ .a11y-issues {
99
+ margin: 0;
100
+ padding: 0;
101
+ list-style: none;
102
+ }
103
+ </style>
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import A11yIssueRow from './A11yIssueRow.svelte';
3
+ import {
4
+ componentLabel,
5
+ elementsLabel,
6
+ findingText,
7
+ guidanceLabel,
8
+ propertyLabel,
9
+ type A11yFindingRow,
10
+ } from './a11yView';
11
+
12
+ interface Props {
13
+ component: string;
14
+ row: A11yFindingRow;
15
+ review?: boolean;
16
+ /** Leads the text, as the component's name does in a list that spans components. */
17
+ prefix?: string;
18
+ onreveal?: (variable: string) => void;
19
+ /** Opens the component, for a row that traced no property. Absent on the component's own page. */
20
+ onopen?: () => void;
21
+ }
22
+
23
+ let { component, row, review = false, prefix, onreveal, onopen }: Props = $props();
24
+
25
+ let properties = $derived(row.variables.map((variable) => ({ variable, label: propertyLabel(component, variable) ?? variable })));
26
+ let where = $derived([row.targets.length > 1 ? elementsLabel(row.targets.length) : '', row.views].filter(Boolean).join(', '));
27
+ let helpUrl = $derived(row.finding.details?.helpUrl);
28
+ let hasLinks = $derived((properties.length > 0 && onreveal !== undefined) || (properties.length === 0 && onopen !== undefined));
29
+ </script>
30
+
31
+ {#snippet location()}
32
+ {#if row.targets.length === 1}<code>{row.targets[0]}</code>{#if row.views}{`, ${row.views}`}{/if}{:else}{where}{/if}
33
+ {/snippet}
34
+
35
+ {#snippet links()}
36
+ {#if properties.length > 0 && onreveal}
37
+ {#each properties as { variable, label } (variable)}
38
+ <button type="button" class="a11y-link" aria-label={`Go to ${label}`} title={variable} onclick={() => onreveal(variable)}>
39
+ {label} <i class="fas fa-arrow-right" aria-hidden="true"></i>
40
+ </button>
41
+ {/each}
42
+ {:else if onopen}
43
+ <button type="button" class="a11y-link" onclick={onopen}>
44
+ Open {componentLabel(component)} <i class="fas fa-arrow-right" aria-hidden="true"></i>
45
+ </button>
46
+ {/if}
47
+ {/snippet}
48
+
49
+ <A11yIssueRow
50
+ {review}
51
+ href={helpUrl}
52
+ linkLabel={helpUrl ? guidanceLabel(helpUrl) : undefined}
53
+ detail={row.targets.length > 0 || row.views ? location : undefined}
54
+ links={hasLinks ? links : undefined}
55
+ >
56
+ {#if prefix}{prefix}: {/if}{findingText(row.finding)}
57
+ </A11yIssueRow>
@@ -0,0 +1,121 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ interface Props {
5
+ /** A result to check by eye: its text in grey, where an issue's is amber. */
6
+ review?: boolean;
7
+ href?: string;
8
+ /** Names what `href` points at, such as "Rule" or "Success criterion 1.4.11". */
9
+ linkLabel?: string;
10
+ children: Snippet;
11
+ detail?: Snippet;
12
+ /** The in-editor links that lead the link group, such as the traced properties. */
13
+ links?: Snippet;
14
+ }
15
+
16
+ let { review = false, href, linkLabel = 'Guidance', children, detail, links }: Props = $props();
17
+ </script>
18
+
19
+ <li class="a11y-issue" class:review>
20
+ <div class="a11y-issue-head">
21
+ <p class="a11y-issue-text">{@render children()}</p>
22
+ {#if links || href}
23
+ <div class="a11y-issue-links">
24
+ {@render links?.()}
25
+ {#if href}
26
+ <a
27
+ class="a11y-link a11y-issue-guidance"
28
+ {href}
29
+ target="_blank"
30
+ rel="noopener"
31
+ title={href}
32
+ aria-label={`${linkLabel} (opens in a new tab)`}
33
+ >
34
+ {linkLabel}
35
+ <i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i>
36
+ </a>
37
+ {/if}
38
+ </div>
39
+ {/if}
40
+ </div>
41
+ {#if detail}
42
+ <p class="a11y-issue-detail">{@render detail()}</p>
43
+ {/if}
44
+ </li>
45
+
46
+ <style>
47
+ .a11y-issue {
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: var(--ui-space-4);
51
+ padding: var(--ui-space-12) 0;
52
+ }
53
+
54
+ .a11y-issue + :global(.a11y-issue) {
55
+ border-top: 1px solid var(--ui-border-low);
56
+ }
57
+
58
+ .a11y-issue-head {
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ align-items: center;
62
+ gap: var(--ui-space-8) var(--ui-space-16);
63
+ }
64
+
65
+ .a11y-issue-text,
66
+ .a11y-issue-detail {
67
+ margin: 0;
68
+ overflow-wrap: anywhere;
69
+ }
70
+
71
+ .a11y-issue-text {
72
+ flex: 1 1 16rem;
73
+ min-width: 0;
74
+ font-size: var(--ui-font-size-md);
75
+ color: var(--ui-highlight);
76
+ }
77
+
78
+ .review .a11y-issue-text {
79
+ color: var(--ui-text-secondary);
80
+ }
81
+
82
+ .a11y-issue-detail {
83
+ font-size: var(--ui-font-size-sm);
84
+ color: var(--ui-text-tertiary);
85
+ }
86
+
87
+ .a11y-issue-links {
88
+ display: flex;
89
+ flex-wrap: wrap;
90
+ align-items: center;
91
+ gap: var(--ui-space-6);
92
+ margin-left: auto;
93
+ }
94
+
95
+ .a11y-issue-links :global(.a11y-link) {
96
+ display: inline-flex;
97
+ align-items: center;
98
+ gap: var(--ui-space-6);
99
+ padding: var(--ui-space-2) var(--ui-space-10);
100
+ background: none;
101
+ border: 1px solid var(--ui-border);
102
+ border-radius: var(--ui-radius-full);
103
+ font: inherit;
104
+ font-size: var(--ui-font-size-sm);
105
+ color: var(--ui-text-secondary);
106
+ text-decoration: none;
107
+ white-space: nowrap;
108
+ cursor: pointer;
109
+ transition: background var(--ui-transition-fast), border-color var(--ui-transition-fast), color var(--ui-transition-fast);
110
+ }
111
+
112
+ .a11y-issue-links :global(.a11y-link:hover) {
113
+ background: var(--ui-surface-higher);
114
+ border-color: var(--ui-border-high);
115
+ color: var(--ui-text-primary);
116
+ }
117
+
118
+ .a11y-issue-links :global(.a11y-link i) {
119
+ font-size: var(--ui-font-size-xs);
120
+ }
121
+ </style>