@motion-proto/live-tokens 0.40.0 → 0.41.0

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 (89) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist-plugin/{chunk-D77VD4Z6.js → chunk-H4TRUINI.js} +102 -13
  3. package/dist-plugin/index.cjs +288 -67
  4. package/dist-plugin/index.js +171 -55
  5. package/dist-plugin/tokensCssMigrations/index.cjs +102 -13
  6. package/dist-plugin/tokensCssMigrations/index.js +1 -1
  7. package/package.json +3 -2
  8. package/src/app/site.css +97 -20
  9. package/src/editor/core/palettes/colorHarmony.ts +228 -0
  10. package/src/editor/core/palettes/contrast.ts +128 -0
  11. package/src/editor/core/palettes/oklch.ts +8 -0
  12. package/src/editor/core/palettes/paletteDerivation.ts +267 -72
  13. package/src/editor/core/palettes/recommendText.ts +216 -0
  14. package/src/editor/core/palettes/solveTextContrast.ts +251 -0
  15. package/src/editor/core/store/editorCore.ts +2 -1
  16. package/src/editor/core/store/editorPersistence.ts +18 -1
  17. package/src/editor/core/store/editorStore.ts +20 -1
  18. package/src/editor/core/store/editorTypes.ts +4 -0
  19. package/src/editor/core/store/editorViewStore.ts +7 -3
  20. package/src/editor/core/themes/migrations/2026-06-05-palette-unification.ts +11 -9
  21. package/src/editor/core/themes/migrations/2026-07-21-palette-oklch-basis.ts +51 -0
  22. package/src/editor/core/themes/migrations/2026-07-24-base-anchor-placement.ts +37 -0
  23. package/src/editor/core/themes/migrations/2026-07-25-background-spot-to-base.ts +40 -0
  24. package/src/editor/core/themes/themeTypes.ts +23 -3
  25. package/src/editor/docs/CodeBlock.svelte +1 -1
  26. package/src/editor/docs/Docs.svelte +30 -13
  27. package/src/editor/pages/EditorShell.svelte +31 -3
  28. package/src/editor/ui/ColorEditPanel.svelte +22 -16
  29. package/src/editor/ui/EditorViewSwitcher.svelte +43 -9
  30. package/src/editor/ui/FontStackEditor.svelte +0 -1
  31. package/src/editor/ui/PaletteEditor.svelte +117 -155
  32. package/src/editor/ui/ProjectFontsSection.svelte +0 -3
  33. package/src/editor/ui/TextStylesSection.svelte +173 -0
  34. package/src/editor/ui/UILineHeightSelector.svelte +6 -5
  35. package/src/editor/ui/UIPaletteSelector.svelte +6 -1
  36. package/src/editor/ui/UITextTransformSelector.svelte +106 -0
  37. package/src/editor/ui/VariablesTab.svelte +23 -10
  38. package/src/editor/ui/colors/ColorReadouts.svelte +123 -0
  39. package/src/editor/ui/colors/ColorStory.svelte +375 -0
  40. package/src/editor/ui/colors/ColorWheel.svelte +740 -0
  41. package/src/editor/ui/colors/ColorsTab.svelte +508 -0
  42. package/src/editor/ui/colors/HarmonyAxesList.svelte +320 -0
  43. package/src/editor/ui/colors/LightnessBar.svelte +261 -0
  44. package/src/editor/ui/colors/PaletteStepStrip.svelte +142 -0
  45. package/src/editor/ui/colors/colorWheelMath.ts +9 -0
  46. package/src/editor/ui/colors/harmony-icons/analogous.svg +13 -0
  47. package/src/editor/ui/colors/harmony-icons/complementary.svg +10 -0
  48. package/src/editor/ui/colors/harmony-icons/compound.svg +15 -0
  49. package/src/editor/ui/colors/harmony-icons/custom.svg +10 -0
  50. package/src/editor/ui/colors/harmony-icons/monochromatic.svg +11 -0
  51. package/src/editor/ui/colors/harmony-icons/shades.svg +9 -0
  52. package/src/editor/ui/colors/harmony-icons/split-complementary.svg +13 -0
  53. package/src/editor/ui/colors/harmony-icons/square.svg +13 -0
  54. package/src/editor/ui/colors/harmony-icons/tetradic.svg +15 -0
  55. package/src/editor/ui/colors/harmony-icons/triadic.svg +13 -0
  56. package/src/editor/ui/colors/harmonyModeIcons.ts +23 -0
  57. package/src/editor/ui/colors/paletteBaseColor.ts +214 -0
  58. package/src/editor/ui/index.ts +0 -1
  59. package/src/editor/ui/palette/OverridesPanel.svelte +6 -3
  60. package/src/editor/ui/palette/PaletteBase.svelte +16 -8
  61. package/src/editor/ui/palette/dockMagnify.ts +22 -0
  62. package/src/editor/ui/palette/paletteEditorState.ts +5 -4
  63. package/src/editor/ui/palette/paletteMath.ts +93 -181
  64. package/src/editor/ui/sections/textStyles.ts +82 -0
  65. package/src/editor/ui/sections/tokenScales.ts +3 -3
  66. package/src/live-tokens/data/themes/default.json +3 -3
  67. package/src/live-tokens/data/tokens.generated.css +96 -95
  68. package/src/system/components/Badge.svelte +11 -11
  69. package/src/system/components/Button.svelte +7 -7
  70. package/src/system/components/Callout.svelte +8 -8
  71. package/src/system/components/Card.svelte +2 -2
  72. package/src/system/components/CodeSnippet.svelte +1 -1
  73. package/src/system/components/CollapsibleSection.svelte +7 -7
  74. package/src/system/components/CornerBadge.svelte +1 -1
  75. package/src/system/components/Dialog.svelte +2 -2
  76. package/src/system/components/FloatingTokenTags.css +6 -5
  77. package/src/system/components/Input.svelte +6 -6
  78. package/src/system/components/MenuSelect.svelte +4 -4
  79. package/src/system/components/Notification.svelte +8 -8
  80. package/src/system/components/ProgressBar.svelte +2 -2
  81. package/src/system/components/RadioButton.svelte +4 -4
  82. package/src/system/components/SectionDivider.svelte +6 -6
  83. package/src/system/components/SegmentedControl.svelte +5 -5
  84. package/src/system/components/SideNavigation.svelte +12 -12
  85. package/src/system/components/TabBar.svelte +4 -4
  86. package/src/system/components/Table.svelte +2 -2
  87. package/src/system/components/Tooltip.svelte +1 -1
  88. package/src/system/styles/tokens.css +59 -6
  89. package/src/editor/ui/TextTab.svelte +0 -203
@@ -0,0 +1,173 @@
1
+ <script lang="ts">
2
+ import UIFontFamilySelector from './UIFontFamilySelector.svelte';
3
+ import UIFontSizeSelector from './UIFontSizeSelector.svelte';
4
+ import UIFontWeightSelector from './UIFontWeightSelector.svelte';
5
+ import UILetterSpacingSelector from './UILetterSpacingSelector.svelte';
6
+ import UILineHeightSelector from './UILineHeightSelector.svelte';
7
+ import UITextTransformSelector from './UITextTransformSelector.svelte';
8
+ import { TEXT_STYLES } from './sections/textStyles';
9
+ </script>
10
+
11
+ <div class="text-styles">
12
+ {#each TEXT_STYLES as style (style.prefix)}
13
+ <div class="ts-card">
14
+ <div class="ts-id">
15
+ <code class="ts-name">{style.name}</code>
16
+ <span class="ts-el">{style.defaultElement}</span>
17
+ </div>
18
+
19
+ <div
20
+ class="ts-preview"
21
+ style="font-family: var({style.prefix}-font-family); font-size: var({style.prefix}-font-size); font-weight: var({style.prefix}-font-weight); line-height: var({style.prefix}-line-height); letter-spacing: var({style.prefix}-letter-spacing);{style.hasTextTransform ? ` text-transform: var(${style.prefix}-text-transform);` : ''}"
22
+ >{style.preview}</div>
23
+
24
+ <div class="ts-controls">
25
+ <div class="ts-field">
26
+ <span class="ts-label">family</span>
27
+ <UIFontFamilySelector variable={`${style.prefix}-font-family`} />
28
+ </div>
29
+ <div class="ts-field">
30
+ <span class="ts-label">size</span>
31
+ <UIFontSizeSelector variable={`${style.prefix}-font-size`} />
32
+ </div>
33
+ <div class="ts-field">
34
+ <span class="ts-label">weight</span>
35
+ <UIFontWeightSelector variable={`${style.prefix}-font-weight`} />
36
+ </div>
37
+ <div class="ts-field">
38
+ <span class="ts-label">letter spacing</span>
39
+ <UILetterSpacingSelector variable={`${style.prefix}-letter-spacing`} />
40
+ </div>
41
+ <div class="ts-field">
42
+ <span class="ts-label">line height</span>
43
+ <UILineHeightSelector variable={`${style.prefix}-line-height`} />
44
+ </div>
45
+ {#if style.hasTextTransform}
46
+ <div class="ts-field">
47
+ <span class="ts-label">transform</span>
48
+ <UITextTransformSelector variable={`${style.prefix}-text-transform`} />
49
+ </div>
50
+ {/if}
51
+ </div>
52
+ </div>
53
+ {/each}
54
+ </div>
55
+
56
+ <style>
57
+ /* One frame around the stacked list; cards butt together and are separated
58
+ by a faint rule rather than each being its own box. No overflow clipping —
59
+ the pickers' dropdowns must be free to spill past the frame. */
60
+ .text-styles {
61
+ display: flex;
62
+ flex-direction: column;
63
+ border: 1px solid var(--ui-border);
64
+ }
65
+
66
+ /* Two columns: a fixed identity rail and the content column holding the
67
+ preview (top) then the controls (below). The rail spans both rows so a
68
+ stack of cards reads as one continuous, scannable left column. */
69
+ .ts-card {
70
+ display: grid;
71
+ grid-template-columns: 8rem minmax(0, 1fr);
72
+ column-gap: var(--ui-space-12);
73
+ row-gap: var(--ui-space-8);
74
+ padding: var(--ui-space-12) var(--ui-space-10);
75
+ border-bottom: 1px solid var(--ui-border-low);
76
+ }
77
+
78
+ .ts-card:last-child {
79
+ border-bottom: none;
80
+ }
81
+
82
+ .ts-id {
83
+ grid-column: 1;
84
+ grid-row: 1 / span 2;
85
+ display: flex;
86
+ flex-direction: column;
87
+ gap: var(--ui-space-2);
88
+ min-width: 0;
89
+ }
90
+
91
+ .ts-name {
92
+ font-family: var(--ui-font-mono);
93
+ font-size: var(--ui-font-size-sm);
94
+ color: var(--ui-text-primary);
95
+ }
96
+
97
+ .ts-el {
98
+ font-family: var(--ui-font-mono);
99
+ font-size: var(--ui-font-size-xs);
100
+ color: var(--ui-text-muted);
101
+ }
102
+
103
+ /* Fixed-height preview well with the sample pinned to the top, so its
104
+ cap-line meets the identity label instead of floating mid-row. Height
105
+ clears the largest style (heading-xl) so every well is uniform and the
106
+ rows keep a steady rhythm regardless of sample size. Preview renders
107
+ through the bundle's own vars (letterforms, not ink); ink stays the
108
+ editor's text colour since colour is outside the style bundle. */
109
+ .ts-preview {
110
+ grid-column: 2;
111
+ grid-row: 1;
112
+ align-self: start;
113
+ min-height: 3rem;
114
+ color: var(--ui-text-primary);
115
+ max-width: 100%;
116
+ min-width: 0;
117
+ text-align: left;
118
+ overflow: hidden;
119
+ text-overflow: ellipsis;
120
+ white-space: nowrap;
121
+ }
122
+
123
+ /* Fixed-width tracks packed to the left rather than stretching across the
124
+ full-bleed section, so the axis pickers stay a tight, scannable cluster.
125
+ auto-fill wraps them when the panel is too narrow for one row. */
126
+ .ts-controls {
127
+ grid-column: 2;
128
+ grid-row: 2;
129
+ display: grid;
130
+ grid-template-columns: repeat(auto-fill, minmax(6.5rem, 7.5rem));
131
+ justify-content: start;
132
+ gap: var(--ui-space-12);
133
+ align-items: start;
134
+ }
135
+
136
+ .ts-field {
137
+ display: flex;
138
+ flex-direction: column;
139
+ gap: var(--ui-space-4);
140
+ min-width: 0;
141
+ }
142
+
143
+ /* The shared picker renders trigger + resolved value side by side (a subgrid
144
+ spanning two parent tracks). Here we want them stacked — label, control,
145
+ then the resolved value beneath — so flip its own layout to a column and
146
+ left-align the value the component otherwise centres. */
147
+ .ts-field :global(.ui-token-selector) {
148
+ display: flex;
149
+ flex-direction: column;
150
+ align-items: flex-start;
151
+ gap: var(--ui-space-4);
152
+ }
153
+
154
+ .ts-field :global(.ui-ts-trigger-wrap) {
155
+ width: 100%;
156
+ min-width: 0;
157
+ }
158
+
159
+ .ts-field :global(.ui-ts-meta-text) {
160
+ align-self: flex-start;
161
+ }
162
+
163
+ /* Quiet eyebrow-scale labels so the eye lands on the control and its value,
164
+ not the field name. */
165
+ .ts-label {
166
+ font-size: var(--ui-font-size-xs);
167
+ font-weight: var(--ui-font-weight-medium);
168
+ letter-spacing: 0.04em;
169
+ text-transform: uppercase;
170
+ color: var(--ui-text-muted);
171
+ white-space: nowrap;
172
+ }
173
+ </style>
@@ -21,11 +21,12 @@
21
21
  }: Props = $props();
22
22
 
23
23
  const options = [
24
- { key: 'xs', label: 'Extra Small', value: '1' },
25
- { key: 'sm', label: 'Small', value: '1.25' },
26
- { key: 'md', label: 'Medium', value: '1.5' },
27
- { key: 'lg', label: 'Large', value: '1.75' },
28
- { key: 'xl', label: 'Extra Large', value: '2' },
24
+ { key: 'none', label: 'None', value: '1' },
25
+ { key: 'tightest', label: 'Tightest', value: '1.1' },
26
+ { key: 'tighter', label: 'Tighter', value: '1.25' },
27
+ { key: 'tight', label: 'Tight', value: '1.35' },
28
+ { key: 'normal', label: 'Normal', value: '1.5' },
29
+ { key: 'relaxed', label: 'Relaxed', value: '1.75' },
29
30
  ] as const;
30
31
  </script>
31
32
 
@@ -113,6 +113,10 @@
113
113
  const borderStepKeys = borderSteps.map(s => s.key);
114
114
  const textStepKeys = textSteps.map(s => s.key);
115
115
 
116
+ /** Neutral additionally exposes the derived flip of primary; chromatic
117
+ * families have no inverted step. */
118
+ const neutralTextSteps = [...textSteps, { key: 'inverted', label: 'Inverted' }];
119
+
116
120
  const familiesWithText = ['neutral', 'alternate', 'canvas', 'brand', 'accent', 'special', 'success', 'warning', 'info', 'danger'];
117
121
 
118
122
  const allCategories: { id: Category; label: string }[] = [
@@ -205,6 +209,7 @@
205
209
  if (varName === '--text-tertiary') return { family: 'neutral', step: 'tertiary' };
206
210
  if (varName === '--text-muted') return { family: 'neutral', step: 'muted' };
207
211
  if (varName === '--text-disabled') return { family: 'neutral', step: 'disabled' };
212
+ if (varName === '--text-inverted') return { family: 'neutral', step: 'inverted' };
208
213
  const m = varName.match(/^--text-([a-z]+)(?:-([a-z]+))?$/);
209
214
  if (m) {
210
215
  const fam = m[1];
@@ -702,7 +707,7 @@
702
707
  </div>
703
708
  {:else if selectedTab === 'text'}
704
709
  <div class="step-grid">
705
- {#each textSteps as step}
710
+ {#each (selectedFamily === 'neutral' ? neutralTextSteps : textSteps) as step}
706
711
  <button
707
712
  class="step-item"
708
713
  class:active={chosenCategory === 'text' && chosenFamily === selectedFamily && chosenStep === step.key}
@@ -0,0 +1,106 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { CSS_VAR_CHANGE_EVENT } from '../core/cssVarSync';
4
+ import UITokenSelector from './UITokenSelector.svelte';
5
+ import UIOptionList from './UIOptionList.svelte';
6
+ import UIOptionItem from './UIOptionItem.svelte';
7
+
8
+ interface Props {
9
+ variable: string;
10
+ component?: string | undefined;
11
+ canBeLinked?: boolean;
12
+ disabled?: boolean;
13
+ selectionsLocked?: boolean;
14
+ onchange?: () => void;
15
+ }
16
+
17
+ let {
18
+ variable,
19
+ component = undefined,
20
+ canBeLinked = false,
21
+ disabled = false,
22
+ selectionsLocked = false,
23
+ onchange,
24
+ }: Props = $props();
25
+
26
+ // text-transform is a literal CSS keyword, not a scale alias — the picker
27
+ // writes the keyword itself through UITokenSelector.writeOverride.
28
+ const options = [
29
+ { key: 'none', label: 'None', sample: 'Overline' },
30
+ { key: 'uppercase', label: 'Uppercase', sample: 'Overline' },
31
+ { key: 'lowercase', label: 'Lowercase', sample: 'Overline' },
32
+ { key: 'capitalize', label: 'Capitalize', sample: 'overline label' },
33
+ ] as const;
34
+
35
+ let selector: UITokenSelector | undefined = $state();
36
+ let current = $state('');
37
+
38
+ function readResolved() {
39
+ current = getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
40
+ }
41
+
42
+ function handleVarChange(e: Event) {
43
+ const detail = (e as CustomEvent<{ name: string }>).detail;
44
+ if (detail?.name === variable) readResolved();
45
+ }
46
+
47
+ function select(keyword: string, close: () => void) {
48
+ selector?.writeOverride(keyword);
49
+ readResolved();
50
+ close();
51
+ onchange?.();
52
+ }
53
+
54
+ function handleReset() {
55
+ readResolved();
56
+ onchange?.();
57
+ }
58
+
59
+ onMount(() => {
60
+ readResolved();
61
+ document.addEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
62
+ });
63
+ onDestroy(() => {
64
+ document.removeEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
65
+ });
66
+
67
+ let activeKey = $derived(options.find((o) => o.key === current)?.key ?? null);
68
+ let activeLabel = $derived(options.find((o) => o.key === activeKey)?.label ?? current);
69
+ </script>
70
+
71
+ <UITokenSelector
72
+ bind:this={selector}
73
+ {variable}
74
+ {component}
75
+ {canBeLinked}
76
+ {disabled}
77
+ {selectionsLocked}
78
+ dropdownMinWidth="12rem"
79
+ onreset={handleReset}
80
+ onvarChange={readResolved}
81
+ >
82
+ {#snippet triggerTitle()}{activeLabel}{/snippet}
83
+ {#snippet triggerMeta()}{current || '—'}{/snippet}
84
+
85
+ {#snippet children({ close })}
86
+ <UIOptionList>
87
+ {#each options as opt (opt.key)}
88
+ <UIOptionItem active={activeKey === opt.key} onclick={() => select(opt.key, close)}>
89
+ {#snippet preview()}
90
+ <span class="tt-sample" style="text-transform: {opt.key};">{opt.sample}</span>
91
+ {/snippet}
92
+ {#snippet label()}{opt.label}{/snippet}
93
+ </UIOptionItem>
94
+ {/each}
95
+ </UIOptionList>
96
+ {/snippet}
97
+ </UITokenSelector>
98
+
99
+ <style>
100
+ .tt-sample {
101
+ display: inline-block;
102
+ min-width: 4.5rem;
103
+ font-size: var(--ui-font-size-sm);
104
+ color: var(--ui-text-primary);
105
+ }
106
+ </style>
@@ -1,8 +1,10 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
3
  import PaletteEditor from './PaletteEditor.svelte';
4
+ import { PALETTE_SPECS } from '../core/palettes/paletteDerivation';
4
5
  import FontStackEditor from './FontStackEditor.svelte';
5
6
  import ProjectFontsSection from './ProjectFontsSection.svelte';
7
+ import TextStylesSection from './TextStylesSection.svelte';
6
8
  import ColumnsSection from './sections/ColumnsSection.svelte';
7
9
  import TokenScaleTable from './sections/TokenScaleTable.svelte';
8
10
  import OverlaysSection from './sections/OverlaysSection.svelte';
@@ -49,16 +51,16 @@
49
51
  <h2 class="section-title">Palette Editor</h2>
50
52
  <p class="editor-intro">Derived palettes via <code>color-mix(in oklch)</code>. Change a base color to update all derived steps. Click any derived swatch to add a manual override.</p>
51
53
  <div class="palette-editors">
52
- <PaletteEditor label="Brand" initialColor="#c93636" cssNamespace="brand" />
53
- <PaletteEditor label="Accent" initialColor="#f49e0b" cssNamespace="accent" />
54
- <PaletteEditor label="Background" initialColor="#1a1a2e" cssNamespace="canvas" emptySelector />
55
- <PaletteEditor neutral label="Neutral" initialColor="#70787e" cssNamespace="neutral"/>
56
- <PaletteEditor neutral label="Alternate" displayLabel="Alternate (neutral)" initialColor="#817b78" cssNamespace="alternate" />
57
- <PaletteEditor label="Special" initialColor="#8b5cf6" cssNamespace="special" />
58
- <PaletteEditor label="Info" initialColor="#3077e8" cssNamespace="info" />
59
- <PaletteEditor label="Success" initialColor="#21c45d" cssNamespace="success" />
60
- <PaletteEditor label="Warning" initialColor="#e66e1a" cssNamespace="warning" />
61
- <PaletteEditor label="Danger" initialColor="#e8304f" cssNamespace="danger" />
54
+ {#each PALETTE_SPECS as spec (spec.label)}
55
+ <PaletteEditor
56
+ label={spec.label}
57
+ displayLabel={spec.displayLabel ?? null}
58
+ initialColor={spec.initialColor}
59
+ neutral={spec.neutral ?? false}
60
+ cssNamespace={spec.cssNamespace}
61
+ emptySelector={spec.emptySelector ?? false}
62
+ />
63
+ {/each}
62
64
  </div>
63
65
  </section>
64
66
 
@@ -97,6 +99,11 @@
97
99
  <FontStackEditor />
98
100
  </div>
99
101
 
102
+ <div class="typography-group text-styles-group">
103
+ <h3 class="group-title">Text Styles</h3>
104
+ <TextStylesSection />
105
+ </div>
106
+
100
107
  <div class="typography-group">
101
108
  <h3 class="group-title">Font Sizes</h3>
102
109
  <TokenScaleTable kind="font-size" vars={FONT_SIZE_VARS} {liveVersion} {copiedVar} oncopy={copyVariable} />
@@ -248,6 +255,12 @@
248
255
  gap: var(--ui-space-32);
249
256
  }
250
257
 
258
+ /* The definition table is wide (four pickers + preview per row); give it the
259
+ full row so it isn't squeezed into an auto-fill column. */
260
+ .text-styles-group {
261
+ grid-column: 1 / -1;
262
+ }
263
+
251
264
  /* Utility Tokens */
252
265
  .utility-columns {
253
266
  display: grid;
@@ -0,0 +1,123 @@
1
+ <script lang="ts">
2
+ import { oklchToHexClamped, type Oklch } from '../../core/palettes/oklch';
3
+ import { showCopyPopover } from '../copyPopover';
4
+
5
+ interface Props {
6
+ color: Oklch;
7
+ }
8
+
9
+ let { color }: Props = $props();
10
+
11
+ // The basis is numeric OKLCH; hex/RGB/HSL are clamped sRGB projections off it.
12
+ let hex = $derived(oklchToHexClamped(color.l, color.c, color.h));
13
+
14
+ function toRgb(h: string): [number, number, number] {
15
+ return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
16
+ }
17
+
18
+ function toHsl(r: number, g: number, b: number): [number, number, number] {
19
+ const rn = r / 255, gn = g / 255, bn = b / 255;
20
+ const max = Math.max(rn, gn, bn);
21
+ const min = Math.min(rn, gn, bn);
22
+ const l = (max + min) / 2;
23
+ let h = 0;
24
+ let s = 0;
25
+ if (max !== min) {
26
+ const d = max - min;
27
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
28
+ if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0);
29
+ else if (max === gn) h = (bn - rn) / d + 2;
30
+ else h = (rn - gn) / d + 4;
31
+ h *= 60;
32
+ }
33
+ return [Math.round(h), Math.round(s * 100), Math.round(l * 100)];
34
+ }
35
+
36
+ let rows = $derived.by(() => {
37
+ const [r, g, b] = toRgb(hex);
38
+ const [hh, ss, ll] = toHsl(r, g, b);
39
+ return [
40
+ { label: 'HEX', value: hex },
41
+ { label: 'RGB', value: `rgb(${r}, ${g}, ${b})` },
42
+ { label: 'HSL', value: `hsl(${hh}, ${ss}%, ${ll}%)` },
43
+ { label: 'OKLCH', value: `oklch(${Math.round(color.l * 100)}% ${color.c.toFixed(3)} ${Math.round(color.h)})` },
44
+ ];
45
+ });
46
+
47
+ let copied: string | null = $state(null);
48
+ function copy(label: string, value: string, e: MouseEvent) {
49
+ navigator.clipboard?.writeText(value);
50
+ copied = label;
51
+ showCopyPopover(value, e.currentTarget);
52
+ setTimeout(() => { if (copied === label) copied = null; }, 1500);
53
+ }
54
+ </script>
55
+
56
+ <div class="readouts">
57
+ {#each rows as row (row.label)}
58
+ <button class="row" type="button" class:copied={copied === row.label} onclick={(e) => copy(row.label, row.value, e)} title="Copy {row.label}">
59
+ <span class="row-label">{row.label}</span>
60
+ <span class="row-value">{copied === row.label ? 'copied!' : row.value}</span>
61
+ <i class="fas fa-copy" aria-hidden="true"></i>
62
+ </button>
63
+ {/each}
64
+ </div>
65
+
66
+ <style>
67
+ .readouts {
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: var(--ui-space-2);
71
+ }
72
+
73
+ .row {
74
+ display: grid;
75
+ grid-template-columns: 3.5rem 1fr auto;
76
+ align-items: center;
77
+ gap: var(--ui-space-8);
78
+ padding: var(--ui-space-4) var(--ui-space-8);
79
+ background: var(--ui-surface-lowest);
80
+ border: 1px solid var(--ui-border-low);
81
+ border-radius: var(--ui-radius-sm);
82
+ cursor: pointer;
83
+ text-align: left;
84
+ transition: background var(--ui-transition-fast), border-color var(--ui-transition-fast);
85
+ }
86
+
87
+ .row:hover {
88
+ background: var(--ui-surface-low);
89
+ border-color: var(--ui-border);
90
+ }
91
+
92
+ .row-label {
93
+ font-size: var(--ui-font-size-xs);
94
+ font-weight: var(--ui-font-weight-semibold);
95
+ color: var(--ui-text-tertiary);
96
+ text-transform: uppercase;
97
+ letter-spacing: 0.04em;
98
+ }
99
+
100
+ .row-value {
101
+ font-size: var(--ui-font-size-sm);
102
+ font-family: var(--ui-font-mono);
103
+ color: var(--ui-text-primary);
104
+ overflow: hidden;
105
+ text-overflow: ellipsis;
106
+ white-space: nowrap;
107
+ }
108
+
109
+ .row i {
110
+ font-size: var(--ui-font-size-xs);
111
+ color: var(--ui-text-tertiary);
112
+ opacity: 0;
113
+ transition: opacity var(--ui-transition-fast);
114
+ }
115
+
116
+ .row:hover i {
117
+ opacity: 1;
118
+ }
119
+
120
+ .row.copied .row-value {
121
+ color: var(--ui-text-accent);
122
+ }
123
+ </style>