@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.
Files changed (206) 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 +309 -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/bin/set-colors.mjs +1 -0
  13. package/bin/setup-claude.mjs +2 -2
  14. package/dist-plugin/{chunk-A4TBP4MM.js → chunk-SLICH4IH.js} +2 -2
  15. package/dist-plugin/index.cjs +148 -3
  16. package/dist-plugin/index.js +250 -105
  17. package/dist-plugin/setColors/index.cjs +7 -1
  18. package/dist-plugin/setColors/index.d.cts +9 -4
  19. package/dist-plugin/setColors/index.d.ts +9 -4
  20. package/dist-plugin/setColors/index.js +8 -2
  21. package/dist-plugin/setGeometry/index.d.cts +1 -1
  22. package/dist-plugin/setGeometry/index.d.ts +1 -1
  23. package/dist-plugin/setType/index.d.cts +1 -1
  24. package/dist-plugin/setType/index.d.ts +1 -1
  25. package/dist-plugin/{themeTypes-B8_Idrp4.d.cts → themeTypes-BaTQUyac.d.cts} +10 -1
  26. package/dist-plugin/{themeTypes-B8_Idrp4.d.ts → themeTypes-BaTQUyac.d.ts} +10 -1
  27. package/package.json +49 -2
  28. package/src/demo/LabeledSelect.svelte +1 -1
  29. package/src/editor/bootstrap.ts +3 -1
  30. package/src/editor/component-editor/BadgeEditor.svelte +13 -0
  31. package/src/editor/component-editor/ButtonEditor.svelte +33 -2
  32. package/src/editor/component-editor/CalloutEditor.svelte +13 -0
  33. package/src/editor/component-editor/CardEditor.svelte +12 -2
  34. package/src/editor/component-editor/CodeSnippetEditor.svelte +11 -1
  35. package/src/editor/component-editor/CollapsibleSectionEditor.svelte +18 -1
  36. package/src/editor/component-editor/CornerBadgeEditor.svelte +13 -0
  37. package/src/editor/component-editor/DialogEditor.svelte +15 -0
  38. package/src/editor/component-editor/IconButtonEditor.svelte +29 -1
  39. package/src/editor/component-editor/ImageEditor.svelte +20 -9
  40. package/src/editor/component-editor/ImageLightboxEditor.svelte +44 -0
  41. package/src/editor/component-editor/InlineEditActionsEditor.svelte +13 -1
  42. package/src/editor/component-editor/InputEditor.svelte +23 -0
  43. package/src/editor/component-editor/MenuSelectEditor.svelte +27 -1
  44. package/src/editor/component-editor/NotificationEditor.svelte +15 -0
  45. package/src/editor/component-editor/PanelEditor.svelte +1 -1
  46. package/src/editor/component-editor/ProgressBarEditor.svelte +13 -0
  47. package/src/editor/component-editor/RadioButtonEditor.svelte +20 -1
  48. package/src/editor/component-editor/SectionDividerEditor.svelte +41 -13
  49. package/src/editor/component-editor/SegmentedControlEditor.svelte +29 -1
  50. package/src/editor/component-editor/SideNavigationEditor.svelte +40 -1
  51. package/src/editor/component-editor/SlidePagerEditor.svelte +20 -0
  52. package/src/editor/component-editor/SliderEditor.svelte +33 -0
  53. package/src/editor/component-editor/TabBarEditor.svelte +23 -2
  54. package/src/editor/component-editor/TableEditor.svelte +14 -0
  55. package/src/editor/component-editor/ToggleEditor.svelte +17 -0
  56. package/src/editor/component-editor/TooltipEditor.svelte +11 -2
  57. package/src/editor/component-editor/VideoFrameEditor.svelte +8 -0
  58. package/src/editor/component-editor/VideoLightboxEditor.svelte +10 -0
  59. package/src/editor/component-editor/registration.ts +2 -1
  60. package/src/editor/component-editor/registry.ts +69 -27
  61. package/src/editor/component-editor/scaffolding/DemoHeader.svelte +2 -0
  62. package/src/editor/component-editor/scaffolding/StateBlock.svelte +19 -8
  63. package/src/editor/component-editor/scaffolding/TokenLayout.svelte +3 -0
  64. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +70 -10
  65. package/src/editor/component-editor/scaffolding/editorContext.ts +7 -0
  66. package/src/editor/core/a11y/a11yReport.ts +101 -0
  67. package/src/editor/core/a11y/a11yReportTypes.ts +69 -0
  68. package/src/editor/core/a11y/contrastFailures.ts +22 -0
  69. package/src/editor/core/components/contrastPairRegistry.ts +18 -0
  70. package/src/editor/core/palettes/contrast.ts +44 -0
  71. package/src/editor/core/palettes/contrastPairs.ts +79 -0
  72. package/src/editor/core/palettes/resolveColor.ts +77 -0
  73. package/src/editor/core/store/editorPersistence.ts +9 -1
  74. package/src/editor/core/store/editorRenderer.ts +2 -0
  75. package/src/editor/core/store/editorStore.ts +29 -0
  76. package/src/editor/core/store/editorTypes.ts +9 -0
  77. package/src/editor/core/store/editorViewStore.ts +2 -2
  78. package/src/editor/core/store/unsavedEdits.ts +10 -0
  79. package/src/editor/core/themes/buildColors.ts +16 -5
  80. package/src/editor/core/themes/liveStateStream.ts +7 -1
  81. package/src/editor/core/themes/parsers/gradient.ts +5 -2
  82. package/src/editor/core/themes/slices/customColors.ts +226 -0
  83. package/src/editor/core/themes/themeTypes.ts +10 -0
  84. package/src/editor/docs/chapters.ts +1 -0
  85. package/src/editor/docs/content/accessibility.md +362 -0
  86. package/src/editor/docs/content/editing-tokens.md +56 -1
  87. package/src/editor/docs/content.generated.ts +2 -1
  88. package/src/editor/pages/ComponentEditorPage.svelte +10 -1
  89. package/src/editor/pages/EditorShell.svelte +97 -10
  90. package/src/editor/skill-atlas/skillSources.generated.ts +3 -3
  91. package/src/editor/skill-atlas/trees/check-compliance.ts +3 -3
  92. package/src/editor/skill-atlas/trees/create-component.ts +12 -12
  93. package/src/editor/ui/CustomColorReplaceStep.svelte +118 -0
  94. package/src/editor/ui/EditorViewSwitcher.svelte +15 -2
  95. package/src/editor/ui/GradientEditor.svelte +9 -1
  96. package/src/editor/ui/ThemePanel.svelte +4 -4
  97. package/src/editor/ui/UIContrastReadout.svelte +186 -0
  98. package/src/editor/ui/UIInfoPopover.svelte +16 -2
  99. package/src/editor/ui/UIPaletteSelector.svelte +347 -15
  100. package/src/editor/ui/UITokenSelector.svelte +33 -14
  101. package/src/editor/ui/VariablesTab.svelte +3 -0
  102. package/src/editor/ui/a11y/A11yComponentIssues.svelte +103 -0
  103. package/src/editor/ui/a11y/A11yFindingRow.svelte +57 -0
  104. package/src/editor/ui/a11y/A11yIssueRow.svelte +121 -0
  105. package/src/editor/ui/a11y/A11yOverview.svelte +242 -0
  106. package/src/editor/ui/a11y/A11yPanel.svelte +128 -0
  107. package/src/editor/ui/a11y/A11yPropertyMark.svelte +84 -0
  108. package/src/editor/ui/a11y/A11yRailMark.svelte +34 -0
  109. package/src/editor/ui/a11y/AccessibilityTab.svelte +334 -0
  110. package/src/editor/ui/a11y/a11yView.ts +218 -0
  111. package/src/editor/ui/anchorToTrigger.ts +63 -0
  112. package/src/editor/ui/contrastReadout.ts +45 -0
  113. package/src/editor/ui/sections/CustomColorsSection.svelte +254 -0
  114. package/src/live-tokens/data/themes/autumn.json +20 -20
  115. package/src/live-tokens/data/themes/halloween.json +20 -20
  116. package/src/live-tokens/data/themes/midnight-study.json +20 -20
  117. package/src/live-tokens/data/themes/ocean.json +20 -20
  118. package/src/live-tokens/data/themes/royal-velvet.json +20 -20
  119. package/src/live-tokens/data/themes/sketchy.json +20 -20
  120. package/src/live-tokens/data/themes/spring-meadow.json +20 -20
  121. package/src/live-tokens/data/themes/sunset.json +20 -20
  122. package/src/system/components/Badge.svelte +3 -3
  123. package/src/system/components/Button.svelte +9 -3
  124. package/src/system/components/CodeSnippet.svelte +8 -1
  125. package/src/system/components/CornerBadge.svelte +3 -3
  126. package/src/system/components/Dialog.svelte +43 -14
  127. package/src/system/components/IconButton.svelte +1 -1
  128. package/src/system/components/Image.svelte +7 -0
  129. package/src/system/components/ImageLightbox.svelte +26 -26
  130. package/src/system/components/Input.svelte +8 -1
  131. package/src/system/components/MenuSelect.svelte +30 -2
  132. package/src/system/components/Notification.svelte +1 -1
  133. package/src/system/components/ProgressBar.svelte +6 -0
  134. package/src/system/components/RadioButton.svelte +36 -3
  135. package/src/system/components/SegmentedControl.svelte +36 -2
  136. package/src/system/components/SlidePager.svelte +2 -2
  137. package/src/system/components/Slider.svelte +13 -9
  138. package/src/system/components/TabBar.svelte +33 -3
  139. package/src/system/components/Toggle.svelte +2 -2
  140. package/src/system/components/VideoFrame.svelte +1 -1
  141. package/src/system/components/VideoLightbox.svelte +2 -2
  142. package/src/system/internal/focusTrap.ts +35 -0
  143. package/src/testing-js/__aria__/badge.yml +40 -0
  144. package/src/testing-js/__aria__/button.yml +88 -0
  145. package/src/testing-js/__aria__/callout.yml +12 -0
  146. package/src/testing-js/__aria__/card.yml +8 -0
  147. package/src/testing-js/__aria__/codesnippet.yml +21 -0
  148. package/src/testing-js/__aria__/collapsiblesection.yml +23 -0
  149. package/src/testing-js/__aria__/cornerbadge.yml +24 -0
  150. package/src/testing-js/__aria__/dialog.yml +42 -0
  151. package/src/testing-js/__aria__/iconbutton.yml +57 -0
  152. package/src/testing-js/__aria__/image.yml +10 -0
  153. package/src/testing-js/__aria__/imagelightbox.yml +50 -0
  154. package/src/testing-js/__aria__/inlineeditactions.yml +16 -0
  155. package/src/testing-js/__aria__/input.yml +61 -0
  156. package/src/testing-js/__aria__/menuselect.yml +30 -0
  157. package/src/testing-js/__aria__/notification.yml +30 -0
  158. package/src/testing-js/__aria__/panel.yml +2 -0
  159. package/src/testing-js/__aria__/progressbar.yml +2 -0
  160. package/src/testing-js/__aria__/radiobutton.yml +20 -0
  161. package/src/testing-js/__aria__/sectiondivider.yml +9 -0
  162. package/src/testing-js/__aria__/segmentedcontrol.yml +35 -0
  163. package/src/testing-js/__aria__/sidenavigation.yml +304 -0
  164. package/src/testing-js/__aria__/slidepager.yml +72 -0
  165. package/src/testing-js/__aria__/slider.yml +28 -0
  166. package/src/testing-js/__aria__/tabbar.yml +35 -0
  167. package/src/testing-js/__aria__/table.yml +144 -0
  168. package/src/testing-js/__aria__/toggle.yml +12 -0
  169. package/src/testing-js/__aria__/tooltip.yml +2 -0
  170. package/src/testing-js/__aria__/videoframe.yml +6 -0
  171. package/src/testing-js/__aria__/videolightbox.yml +12 -0
  172. package/src/testing-js/{chunk-WIZ6W7UT.js → chunk-3WCLMOM7.js} +2 -2
  173. package/src/testing-js/{chunk-SHBYZUPD.js → chunk-5HNZHIHG.js} +211 -14
  174. package/src/testing-js/chunk-5HNZHIHG.js.map +1 -0
  175. package/src/testing-js/{chunk-NB3NZRBM.js → chunk-7CM5D7PF.js} +5 -1
  176. package/src/testing-js/chunk-7CM5D7PF.js.map +1 -0
  177. package/src/testing-js/chunk-EWSGE27V.js +168 -0
  178. package/src/testing-js/chunk-EWSGE27V.js.map +1 -0
  179. package/src/testing-js/{chunk-GKO27NGN.js → chunk-R54I7YL6.js} +2 -2
  180. package/src/testing-js/{chunk-X6KHEVIG.js → chunk-RWH46IU5.js} +97 -29
  181. package/src/testing-js/chunk-RWH46IU5.js.map +1 -0
  182. package/src/testing-js/chunk-SHLPDNAQ.js +658 -0
  183. package/src/testing-js/chunk-SHLPDNAQ.js.map +1 -0
  184. package/src/testing-js/{chunk-3UKGXCDL.js → chunk-YVVR3GJZ.js} +1 -1
  185. package/src/testing-js/chunk-YVVR3GJZ.js.map +1 -0
  186. package/src/testing-js/component-a11y.contract.js +283 -0
  187. package/src/testing-js/component-a11y.contract.js.map +1 -0
  188. package/src/testing-js/component-alias.contract.js +2 -2
  189. package/src/testing-js/component-behavior.contract.js +2 -2
  190. package/src/testing-js/component-editor.contract.js +6 -5
  191. package/src/testing-js/component-editor.contract.js.map +1 -1
  192. package/src/testing-js/component-render.contract.js +10 -6
  193. package/src/testing-js/component-render.contract.js.map +1 -1
  194. package/src/testing-js/index.d.ts +44 -16
  195. package/src/testing-js/index.js +9 -9
  196. package/src/testing-js/page-compliance.contract.js +163 -18
  197. package/src/testing-js/page-compliance.contract.js.map +1 -1
  198. package/src/testing-js/vitest.js +3 -3
  199. package/template/package.json +9 -0
  200. package/src/editor/ui/keepInViewport.ts +0 -84
  201. package/src/testing-js/chunk-3UKGXCDL.js.map +0 -1
  202. package/src/testing-js/chunk-NB3NZRBM.js.map +0 -1
  203. package/src/testing-js/chunk-SHBYZUPD.js.map +0 -1
  204. package/src/testing-js/chunk-X6KHEVIG.js.map +0 -1
  205. /package/src/testing-js/{chunk-WIZ6W7UT.js.map → chunk-3WCLMOM7.js.map} +0 -0
  206. /package/src/testing-js/{chunk-GKO27NGN.js.map → chunk-R54I7YL6.js.map} +0 -0
@@ -1,13 +1,32 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
+ import { get } from 'svelte/store';
3
4
  import { slide } from 'svelte/transition';
4
5
  import { cubicOut, cubicIn } from 'svelte/easing';
5
6
  import { resolveAliasChain } from '../core/palettes/tokenRegistry';
6
- import { editorState } from '../core/store/editorStore';
7
+ import {
8
+ editorState,
9
+ beginScope,
10
+ commitScope,
11
+ cancelScope,
12
+ beginSliderGesture,
13
+ addCustomColor,
14
+ setCustomColor,
15
+ removeCustomColor,
16
+ customColorUsages,
17
+ replaceAndRemoveCustomColor,
18
+ type Scope,
19
+ } from '../core/store/editorStore';
20
+ import { setCssVar, removeCssVar } from '../core/cssVarSync';
21
+ import { cssColorToOklch, hexToOklch, type Oklch } from '../core/palettes/oklch';
7
22
  import { formatGradientStops } from '../core/themes/slices/gradients';
8
23
  import { formatColorOpacity, parseColorOpacity } from '../core/themes/parsers/colorOpacity';
9
24
  import type { GradientToken } from '../core/store/editorTypes';
10
25
  import UITokenSelector from './UITokenSelector.svelte';
26
+ import ColorEditPanel from './ColorEditPanel.svelte';
27
+ import CustomColorReplaceStep from './CustomColorReplaceStep.svelte';
28
+ import UIContrastReadout from './UIContrastReadout.svelte';
29
+ import { getContrastPairs } from '../core/components/contrastPairRegistry';
11
30
 
12
31
  /** Honor prefers-reduced-motion: `t()` zeroes durations when the OS asks for
13
32
  * less motion, so the detail-row enter/exit slides skip outright. Mirrors
@@ -52,6 +71,9 @@
52
71
  /** Forwarded to UITokenSelector — when set, writes route through this
53
72
  * callback instead of the DOM. See UITokenSelector.onwrite. */
54
73
  onwrite?: (value: string | null) => void;
74
+ /** Set on the picker a replace step opens to choose a stand-in for this
75
+ * custom colour. The picker omits the colour and offers no add or edit. */
76
+ replacing?: string | null;
55
77
  }
56
78
 
57
79
  let {
@@ -66,8 +88,11 @@
66
88
  minOpacity = 0,
67
89
  onchange,
68
90
  onwrite,
91
+ replacing = null,
69
92
  }: Props = $props();
70
93
 
94
+ let hasContrastPairs = $derived(!!component && getContrastPairs(component, variable).length > 0);
95
+
71
96
  const clampOpacity = (n: number) => Math.max(minOpacity, Math.min(100, Math.round(n)));
72
97
  let noneAllowed = $derived(showNone && minOpacity <= 0);
73
98
 
@@ -88,6 +113,12 @@
88
113
 
89
114
  const familyNames = families.map(f => f.name);
90
115
 
116
+ /** The Custom row's family name. A custom colour is its palette step `N`, so
117
+ * `getVarName('palette', CUSTOM, N)` yields `--color-custom-N`. */
118
+ const CUSTOM = 'custom';
119
+ const CUSTOM_PREFIX = '--color-custom-';
120
+ const customNumber = (v: string) => v.slice(CUSTOM_PREFIX.length);
121
+
91
122
  const paletteSteps = ['100', '200', '300', '400', '500', '600', '700', '800', '850', '900', '950'];
92
123
 
93
124
  const surfaceSteps = [
@@ -233,6 +264,9 @@
233
264
  if (!varMatch) return null;
234
265
  const varName = varMatch[1];
235
266
 
267
+ const customM = varName.match(/^--color-custom-(\d+)$/);
268
+ if (customM) return { category: 'palette', family: CUSTOM, step: customM[1] };
269
+
236
270
  const paletteM = varName.match(/^--color-([a-z]+)-(\d{3})$/);
237
271
  if (paletteM && familyNames.includes(paletteM[1]) && paletteSteps.includes(paletteM[2])) {
238
272
  return { category: 'palette', family: paletteM[1], step: paletteM[2] };
@@ -452,6 +486,7 @@
452
486
  }
453
487
 
454
488
  function handleClose() {
489
+ if (session) cancelSession();
455
490
  selectedFamily = null;
456
491
  }
457
492
 
@@ -464,6 +499,7 @@
464
499
  }
465
500
 
466
501
  function backToFamilies() {
502
+ if (session) cancelSession();
467
503
  selectedFamily = null;
468
504
  }
469
505
 
@@ -530,6 +566,130 @@
530
566
  onchange?.();
531
567
  }
532
568
 
569
+ let customTokens = $derived(
570
+ $editorState.customColors.tokens
571
+ .filter((t) => t.variable !== replacing)
572
+ .sort((a, b) => Number(customNumber(a.variable)) - Number(customNumber(b.variable))),
573
+ );
574
+ let customOutOfFamily = $derived(familyFilter !== null && familyFilter !== CUSTOM);
575
+
576
+ /** An open add, edit, or replace-on-remove of one custom colour. */
577
+ interface CustomSession {
578
+ mode: 'add' | 'edit' | 'remove';
579
+ variable: string;
580
+ draft: Oklch;
581
+ }
582
+ let session = $state<CustomSession | null>(null);
583
+ // Held outside $state: the store matches an open scope by identity, and a
584
+ // proxied handle would never match.
585
+ let sessionScope: Scope | null = null;
586
+ let slotRawBeforeAdd = '';
587
+
588
+ function readSlotRaw(): string {
589
+ return document.documentElement.style.getPropertyValue(variable).trim();
590
+ }
591
+
592
+ function rgbChannelsToOklch(r: number, g: number, b: number): Oklch {
593
+ const hex = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
594
+ return hexToOklch(`#${hex(r)}${hex(g)}${hex(b)}`);
595
+ }
596
+
597
+ /** The slot's colour as the browser paints it. A custom property's computed
598
+ * value is its unresolved text, so a probe's `color` does the resolving. */
599
+ function resolvedSlotColor(): Oklch {
600
+ const probe = document.createElement('span');
601
+ probe.style.color = `var(${variable})`;
602
+ document.body.appendChild(probe);
603
+ const css = getComputedStyle(probe).color.trim();
604
+ probe.remove();
605
+ const rgb = css.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i);
606
+ if (rgb) return rgbChannelsToOklch(+rgb[1], +rgb[2], +rgb[3]);
607
+ const srgb = css.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/i);
608
+ if (srgb) return rgbChannelsToOklch(+srgb[1] * 255, +srgb[2] * 255, +srgb[3] * 255);
609
+ return cssColorToOklch(css) ?? { l: 0.6, c: 0, h: 0 };
610
+ }
611
+
612
+ function openAdd() {
613
+ const seed = resolvedSlotColor();
614
+ slotRawBeforeAdd = readSlotRaw();
615
+ sessionScope = beginScope({ label: 'add custom colour', collapseToOne: true, clipUndoFloor: true });
616
+ const added = addCustomColor(seed);
617
+ chosenNone = false;
618
+ chosenStatic = null;
619
+ chosenGradient = null;
620
+ chosenAngle = null;
621
+ chosenCategory = 'palette';
622
+ chosenFamily = CUSTOM;
623
+ chosenStep = customNumber(added);
624
+ selector?.writeOverride(buildValue(added));
625
+ onchange?.();
626
+ session = { mode: 'add', variable: added, draft: seed };
627
+ }
628
+
629
+ function openEdit(customVar: string) {
630
+ const token = get(editorState).customColors.tokens.find((t) => t.variable === customVar);
631
+ if (!token) return;
632
+ sessionScope = beginScope({ label: `edit custom colour ${customVar}`, collapseToOne: true, clipUndoFloor: true });
633
+ session = { mode: 'edit', variable: customVar, draft: { ...token.color } };
634
+ }
635
+
636
+ function editDraft(h: number, c: number, lPct: number) {
637
+ if (!session) return;
638
+ session.draft = { l: lPct / 100, c, h };
639
+ setCustomColor(session.variable, session.draft);
640
+ }
641
+
642
+ function confirmSession() {
643
+ if (!session) return;
644
+ const added = session.mode === 'add';
645
+ if (sessionScope) commitScope(sessionScope);
646
+ sessionScope = null;
647
+ session = null;
648
+ if (added) {
649
+ selectedFamily = null;
650
+ selector?.close();
651
+ onchange?.();
652
+ }
653
+ }
654
+
655
+ // A cancelled add would leave the slot naming a colour the scope removed, so
656
+ // the scope restores the slot with it. A slot that writes only to the DOM
657
+ // sits outside the store, so its prior value goes back by hand.
658
+ function cancelSession() {
659
+ const added = session?.mode === 'add';
660
+ if (sessionScope) cancelScope(sessionScope);
661
+ sessionScope = null;
662
+ session = null;
663
+ if (added && !component && !onwrite) {
664
+ if (slotRawBeforeAdd) setCssVar(variable, slotRawBeforeAdd);
665
+ else removeCssVar(variable);
666
+ }
667
+ initFromCurrent();
668
+ }
669
+
670
+ function requestRemove(customVar: string) {
671
+ if (customColorUsages(get(editorState), customVar).length === 0) {
672
+ removeCustomColor(customVar);
673
+ initFromCurrent();
674
+ return;
675
+ }
676
+ const token = get(editorState).customColors.tokens.find((t) => t.variable === customVar);
677
+ if (!token) return;
678
+ session = { mode: 'remove', variable: customVar, draft: { ...token.color } };
679
+ }
680
+
681
+ function confirmReplace(replacement: string) {
682
+ if (!session) return;
683
+ const customVar = session.variable;
684
+ session = null;
685
+ replaceAndRemoveCustomColor(customVar, replacement);
686
+ initFromCurrent();
687
+ }
688
+
689
+ function cancelReplace() {
690
+ session = null;
691
+ }
692
+
533
693
  // Re-derive trigger state when the bound `variable` changes (e.g. when a
534
694
  // VariantGroup tabs view reuses the same selector instance across states).
535
695
  // The wrapper UITokenSelector forwards `var-change` only for the currently
@@ -567,9 +727,11 @@
567
727
  ? getVarName(chosenCategory, chosenFamily, chosenStep).replace(/^--/, '') + (opacity < 100 ? ` (${opacity}%)` : '')
568
728
  : ''));
569
729
 
570
- let availableTabs = $derived(selectedFamily
571
- ? allCategories.filter(c => c.id !== 'text' || familiesWithText.includes(selectedFamily!))
572
- : allCategories);
730
+ let availableTabs = $derived(selectedFamily === CUSTOM
731
+ ? allCategories.filter(c => c.id === 'palette')
732
+ : selectedFamily
733
+ ? allCategories.filter(c => c.id !== 'text' || familiesWithText.includes(selectedFamily!))
734
+ : allCategories);
573
735
 
574
736
  $effect(() => {
575
737
  const family = selectedFamily;
@@ -582,6 +744,10 @@
582
744
  });
583
745
  </script>
584
746
 
747
+ {#snippet contrastReadout()}
748
+ {#if component}<UIContrastReadout {component} {variable} />{/if}
749
+ {/snippet}
750
+
585
751
  <UITokenSelector
586
752
  bind:this={selector}
587
753
  {variable}
@@ -590,7 +756,8 @@
590
756
  {disabled}
591
757
  {selectionsLocked}
592
758
  {onwrite}
593
- dropdownMinWidth="14rem"
759
+ metaAfter={hasContrastPairs ? contrastReadout : undefined}
760
+ dropdownMinWidth={session ? '22rem' : '14rem'}
594
761
  dropdownMaxWidth="calc(100vw - 2rem)"
595
762
  hideDefaultHeader={!!selectedFamily}
596
763
  onreset={handleReset}
@@ -632,7 +799,7 @@
632
799
  {/if}
633
800
  </div>
634
801
  <div class="family-list">
635
- {#each families as fam}
802
+ {#each families as fam (fam.name)}
636
803
  {@const outOfFamily = familyFilter !== null && fam.name !== familyFilter}
637
804
  <button
638
805
  class="family-item"
@@ -652,9 +819,30 @@
652
819
  {/if}
653
820
  </button>
654
821
  {/each}
822
+ <button
823
+ class="family-item"
824
+ class:active={!chosenNone && chosenFamily === CUSTOM}
825
+ class:out-of-family={customOutOfFamily}
826
+ disabled={customOutOfFamily}
827
+ onclick={() => selectFamily(CUSTOM)}
828
+ >
829
+ <div class="family-swatches">
830
+ {#each [0, 1, 2] as i (i)}
831
+ {#if customTokens[i]}
832
+ <div class="mini-swatch" style="background: var({customTokens[i].variable});"></div>
833
+ {:else}
834
+ <div class="mini-swatch mini-swatch--empty"></div>
835
+ {/if}
836
+ {/each}
837
+ </div>
838
+ <span class="family-label">Custom</span>
839
+ {#if !customOutOfFamily}
840
+ <i class="fas fa-chevron-right family-arrow"></i>
841
+ {/if}
842
+ </button>
655
843
  {#if gradientsAllowed && gradientTokens.length > 0}
656
844
  <div class="family-divider">Gradients</div>
657
- {#each gradientTokens as g}
845
+ {#each gradientTokens as g (g.variable)}
658
846
  <button class="family-item" class:active={chosenGradient === g.variable} onclick={() => selectGradient(g.variable, close)}>
659
847
  <div class="family-swatches">
660
848
  <div class="gradient-swatch" style="background: var({g.variable});"></div>
@@ -667,11 +855,11 @@
667
855
  {:else}
668
856
  <button class="dropdown-back" onclick={backToFamilies}>
669
857
  <i class="fas fa-chevron-left"></i>
670
- <span>{families.find(f => f.name === selectedFamily)?.label}</span>
858
+ <span>{selectedFamily === CUSTOM ? 'Custom' : families.find(f => f.name === selectedFamily)?.label}</span>
671
859
  </button>
672
860
 
673
861
  <div class="tab-bar">
674
- {#each availableTabs as tab}
862
+ {#each availableTabs as tab (tab.id)}
675
863
  <button
676
864
  class="tab-btn"
677
865
  class:selected={selectedCategory === tab.id}
@@ -681,9 +869,79 @@
681
869
  {/each}
682
870
  </div>
683
871
 
684
- {#if selectedCategory === 'palette'}
872
+ {#if selectedFamily === CUSTOM}
685
873
  <div class="step-grid">
686
- {#each paletteSteps as step}
874
+ {#each customTokens as token (token.variable)}
875
+ {@const n = customNumber(token.variable)}
876
+ <div class="custom-cell">
877
+ <button
878
+ class="step-item"
879
+ class:active={chosenCategory === 'palette' && chosenFamily === CUSTOM && chosenStep === n}
880
+ disabled={session !== null}
881
+ onclick={() => selectSwatch('palette', n, close)}
882
+ >
883
+ <div class="step-swatch" style="background: var({token.variable});"></div>
884
+ <span class="step-label">{n}</span>
885
+ </button>
886
+ {#if !replacing}
887
+ <button
888
+ type="button"
889
+ class="custom-edit"
890
+ title="Edit custom colour {n}"
891
+ aria-label="Edit custom colour {n}"
892
+ disabled={session !== null}
893
+ onclick={() => openEdit(token.variable)}
894
+ ><i class="fas fa-pen" aria-hidden="true"></i></button>
895
+ <button
896
+ type="button"
897
+ class="custom-remove"
898
+ title="Remove custom colour {n}"
899
+ aria-label="Remove custom colour {n}"
900
+ disabled={session !== null}
901
+ onclick={() => requestRemove(token.variable)}
902
+ ><i class="fas fa-trash" aria-hidden="true"></i></button>
903
+ {/if}
904
+ </div>
905
+ {/each}
906
+ {#if !replacing}
907
+ <button
908
+ class="step-item"
909
+ title="Add a custom colour"
910
+ aria-label="Add a custom colour"
911
+ disabled={session !== null}
912
+ onclick={openAdd}
913
+ >
914
+ <div class="step-swatch step-swatch--add"><i class="fas fa-plus" aria-hidden="true"></i></div>
915
+ <span class="step-label">Add</span>
916
+ </button>
917
+ {/if}
918
+ </div>
919
+ {#if session}
920
+ <div class="custom-panel">
921
+ {#if session.mode === 'remove'}
922
+ <CustomColorReplaceStep
923
+ variable={session.variable}
924
+ color={session.draft}
925
+ onconfirm={confirmReplace}
926
+ oncancel={cancelReplace}
927
+ />
928
+ {:else}
929
+ <ColorEditPanel
930
+ title="Custom › {customNumber(session.variable)}"
931
+ hue={session.draft.h}
932
+ chroma={session.draft.c}
933
+ lightness={session.draft.l * 100}
934
+ onHueChromaChange={editDraft}
935
+ onSliderStart={() => beginSliderGesture(`edit ${session?.variable ?? 'custom colour'}`)}
936
+ onConfirm={confirmSession}
937
+ onCancel={cancelSession}
938
+ />
939
+ {/if}
940
+ </div>
941
+ {/if}
942
+ {:else if selectedCategory === 'palette'}
943
+ <div class="step-grid">
944
+ {#each paletteSteps as step (step)}
687
945
  <button
688
946
  class="step-item"
689
947
  class:active={chosenCategory === 'palette' && chosenFamily === selectedFamily && chosenStep === step}
@@ -696,7 +954,7 @@
696
954
  </div>
697
955
  {:else if selectedCategory === 'surface'}
698
956
  <div class="step-grid">
699
- {#each surfaceSteps as step}
957
+ {#each surfaceSteps as step (step.key)}
700
958
  <button
701
959
  class="step-item"
702
960
  class:active={chosenCategory === 'surface' && chosenFamily === selectedFamily && chosenStep === step.key}
@@ -709,7 +967,7 @@
709
967
  </div>
710
968
  {:else if selectedCategory === 'border'}
711
969
  <div class="step-grid">
712
- {#each borderSteps as step}
970
+ {#each borderSteps as step (step.key)}
713
971
  <button
714
972
  class="step-item"
715
973
  class:active={chosenCategory === 'border' && chosenFamily === selectedFamily && chosenStep === step.key}
@@ -722,7 +980,7 @@
722
980
  </div>
723
981
  {:else if selectedCategory === 'text'}
724
982
  <div class="step-grid">
725
- {#each (selectedFamily === 'neutral' ? neutralTextSteps : textSteps) as step}
983
+ {#each (selectedFamily === 'neutral' ? neutralTextSteps : textSteps) as step (step.key)}
726
984
  <button
727
985
  class="step-item"
728
986
  class:active={chosenCategory === 'text' && chosenFamily === selectedFamily && chosenStep === step.key}
@@ -756,7 +1014,7 @@
756
1014
  <span class="detail-label">orientation</span>
757
1015
  <div class="orientation-body">
758
1016
  <div class="dir-grid">
759
- {#each directionGrid as d}
1017
+ {#each directionGrid as d (d.angle)}
760
1018
  <button
761
1019
  type="button"
762
1020
  class="dir-btn"
@@ -1061,6 +1319,80 @@
1061
1319
  border-radius: 2px;
1062
1320
  }
1063
1321
 
1322
+ .mini-swatch--empty {
1323
+ border: 1px dashed var(--ui-border);
1324
+ }
1325
+
1326
+ .custom-cell {
1327
+ position: relative;
1328
+ display: flex;
1329
+ }
1330
+
1331
+ .custom-cell > .step-item {
1332
+ flex: 1;
1333
+ }
1334
+
1335
+ .custom-edit,
1336
+ .custom-remove {
1337
+ position: absolute;
1338
+ top: 0;
1339
+ display: inline-flex;
1340
+ align-items: center;
1341
+ justify-content: center;
1342
+ width: 1.125rem;
1343
+ height: 1.125rem;
1344
+ padding: 0;
1345
+ background: var(--ui-surface-high);
1346
+ border: 1px solid var(--ui-border);
1347
+ border-radius: var(--ui-radius-sm);
1348
+ color: var(--ui-text-secondary);
1349
+ font-size: 0.5625rem;
1350
+ cursor: pointer;
1351
+ }
1352
+
1353
+ .custom-edit {
1354
+ right: 0;
1355
+ }
1356
+
1357
+ .custom-remove {
1358
+ left: 0;
1359
+ }
1360
+
1361
+ .custom-edit:hover:not(:disabled),
1362
+ .custom-remove:hover:not(:disabled) {
1363
+ color: var(--ui-text-primary);
1364
+ border-color: var(--ui-border-higher);
1365
+ }
1366
+
1367
+ .custom-edit:disabled,
1368
+ .custom-remove:disabled {
1369
+ cursor: default;
1370
+ opacity: 0.5;
1371
+ }
1372
+
1373
+ .step-item:disabled {
1374
+ cursor: default;
1375
+ opacity: 0.5;
1376
+ }
1377
+
1378
+ .step-item:disabled:hover {
1379
+ background: none;
1380
+ border-color: transparent;
1381
+ }
1382
+
1383
+ .step-swatch--add {
1384
+ display: flex;
1385
+ align-items: center;
1386
+ justify-content: center;
1387
+ border: 1px dashed var(--ui-border-higher);
1388
+ color: var(--ui-text-muted);
1389
+ font-size: var(--ui-font-size-xs);
1390
+ }
1391
+
1392
+ .custom-panel {
1393
+ padding: 0 var(--ui-space-8) var(--ui-space-8);
1394
+ }
1395
+
1064
1396
  .gradient-swatch {
1065
1397
  width: 2.5rem;
1066
1398
  height: 0.75rem;
@@ -22,7 +22,8 @@
22
22
  } from '../core/store/editorStore';
23
23
  import UILinkToggle from './UILinkToggle.svelte';
24
24
  import UIRelinkConfirmDialog from './UIRelinkConfirmDialog.svelte';
25
- import { keepInViewport } from './keepInViewport';
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,
@@ -100,6 +104,7 @@
100
104
 
101
105
  let open = $state(false);
102
106
  let container: HTMLElement;
107
+ let triggerEl = $state<HTMLButtonElement | undefined>(undefined);
103
108
  let relinkOpen = $state(false);
104
109
  let relinkCandidates: { variable: string; alias: string }[] = $state([]);
105
110
 
@@ -291,7 +296,9 @@
291
296
  bind:this={container}
292
297
  >
293
298
  <div class="ui-ts-trigger-wrap">
299
+ <A11yPropertyMark variables={[variable]} placement="gutter" />
294
300
  <button
301
+ bind:this={triggerEl}
295
302
  class="ui-ts-trigger"
296
303
  class:linked={isLinkedDisplay}
297
304
  class:unlinked={showLinkToggle && !isLinkedDisplay}
@@ -329,7 +336,7 @@
329
336
  <div
330
337
  class="ui-ts-dropdown"
331
338
  style="min-width: {dropdownMinWidth};{dropdownMaxWidth ? ` max-width: ${dropdownMaxWidth};` : ''}"
332
- use:keepInViewport
339
+ use:anchorToTrigger={triggerEl!}
333
340
  >
334
341
  {#if !hideDefaultHeader}
335
342
  {#if header}
@@ -360,11 +367,20 @@
360
367
  {/if}
361
368
  </div>
362
369
 
363
- {#if triggerMeta}
370
+ {#snippet metaText(meta: Snippet)}
364
371
  <span
365
372
  class="ui-ts-meta-text"
366
373
  use:mirrorTextToTitle
367
- >{@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)}
368
384
  {/if}
369
385
  </div>
370
386
 
@@ -452,8 +468,7 @@
452
468
  background: var(--ui-surface-high);
453
469
  }
454
470
 
455
- /* Link-state pop-bar. The wrap already has `position: relative` (so the
456
- dropdown can anchor). The bar is a `::before` anchored at the wrap's
471
+ /* Link-state pop-bar. The wrap already has `position: relative`. The bar is a `::before` anchored at the wrap's
457
472
  left edge — same column position regardless of state, so a stack of
458
473
  mixed linked/unlinked rows shares one continuous link-state column.
459
474
  Linked rows: bar is teal, full size, sitting flush against the trigger.
@@ -488,12 +503,6 @@
488
503
  height: 0.875rem;
489
504
  border-radius: 1px;
490
505
  }
491
- /* Keep the dropdown's left edge aligned with the (indented) trigger rather
492
- than the column anchor, so an open dropdown doesn't bleed left of its
493
- associated control. */
494
- .ui-ts-trigger-wrap:has(> .ui-ts-trigger.unlinked) > .ui-ts-dropdown {
495
- left: 0.5rem;
496
- }
497
506
  @media (prefers-reduced-motion: reduce) {
498
507
  .ui-ts-trigger-wrap,
499
508
  .ui-ts-trigger-wrap::before { transition: none; }
@@ -523,6 +532,16 @@
523
532
  min-width: 0;
524
533
  }
525
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
+
526
545
  .ui-ts-chevron {
527
546
  font-size: 0.625rem;
528
547
  color: var(--ui-text-secondary);
@@ -534,8 +553,8 @@
534
553
  }
535
554
 
536
555
  .ui-ts-dropdown {
537
- position: absolute;
538
- top: calc(100% + var(--ui-space-4));
556
+ position: fixed;
557
+ top: 0;
539
558
  left: 0;
540
559
  background: var(--ui-surface-higher);
541
560
  border: 1px solid var(--ui-border-high);
@@ -8,6 +8,7 @@
8
8
  import ColumnsSection from './sections/ColumnsSection.svelte';
9
9
  import TokenScaleTable from './sections/TokenScaleTable.svelte';
10
10
  import WashesSection from './sections/WashesSection.svelte';
11
+ import CustomColorsSection from './sections/CustomColorsSection.svelte';
11
12
  import GradientsSection from './sections/GradientsSection.svelte';
12
13
  import ShadowsSection from './sections/ShadowsSection.svelte';
13
14
  import {
@@ -139,6 +140,8 @@
139
140
  <!-- Overlays -->
140
141
  <WashesSection {copiedVar} oncopy={copyVariable} />
141
142
 
143
+ <CustomColorsSection {copiedVar} oncopy={copyVariable} />
144
+
142
145
  <!-- Gradients -->
143
146
  <GradientsSection {copiedVar} oncopy={copyVariable} />
144
147