@motion-proto/live-tokens 0.3.7 → 0.5.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 (104) hide show
  1. package/README.md +1 -1
  2. package/package.json +11 -9
  3. package/src/component-editor/BadgeEditor.svelte +24 -22
  4. package/src/component-editor/CalloutEditor.svelte +3 -3
  5. package/src/component-editor/CardEditor.svelte +25 -21
  6. package/src/component-editor/CollapsibleSectionEditor.svelte +27 -25
  7. package/src/component-editor/CornerBadgeEditor.svelte +37 -35
  8. package/src/component-editor/DialogEditor.svelte +26 -24
  9. package/src/component-editor/ImageEditor.svelte +11 -9
  10. package/src/component-editor/InlineEditActionsEditor.svelte +17 -15
  11. package/src/component-editor/NotificationEditor.svelte +32 -30
  12. package/src/component-editor/ProgressBarEditor.svelte +3 -3
  13. package/src/component-editor/RadioButtonEditor.svelte +31 -29
  14. package/src/component-editor/SectionDividerEditor.svelte +30 -28
  15. package/src/component-editor/SegmentedControlEditor.svelte +29 -25
  16. package/src/component-editor/StandardButtonsEditor.svelte +42 -38
  17. package/src/component-editor/TabBarEditor.svelte +20 -18
  18. package/src/component-editor/TableEditor.svelte +4 -4
  19. package/src/component-editor/TooltipEditor.svelte +11 -9
  20. package/src/component-editor/registry.ts +2 -2
  21. package/src/component-editor/scaffolding/AngleDial.svelte +20 -19
  22. package/src/component-editor/scaffolding/ComponentEditorBase.svelte +44 -20
  23. package/src/component-editor/scaffolding/ComponentFileManager.svelte +260 -37
  24. package/src/component-editor/scaffolding/ComponentFileMenu.svelte +41 -29
  25. package/src/component-editor/scaffolding/ComponentsTab.svelte +7 -3
  26. package/src/component-editor/scaffolding/CopyFromMenu.svelte +21 -12
  27. package/src/component-editor/scaffolding/DemoHeader.svelte +13 -4
  28. package/src/component-editor/scaffolding/DividerEditor.svelte +27 -14
  29. package/src/component-editor/scaffolding/FieldsetWrapper.svelte +10 -4
  30. package/src/component-editor/scaffolding/GradientCard.svelte +25 -20
  31. package/src/component-editor/scaffolding/LinkageChart.svelte +43 -34
  32. package/src/component-editor/scaffolding/LinkedBlock.svelte +24 -21
  33. package/src/component-editor/scaffolding/NonStylableConfig.svelte +6 -1
  34. package/src/component-editor/scaffolding/SaveAsDialog.svelte +39 -35
  35. package/src/component-editor/scaffolding/ShadowBackdrop.svelte +21 -9
  36. package/src/component-editor/scaffolding/ShadowBackdropControls.svelte +8 -3
  37. package/src/component-editor/scaffolding/StateBlock.svelte +30 -13
  38. package/src/component-editor/scaffolding/TokenLayout.svelte +46 -30
  39. package/src/component-editor/scaffolding/TypeEditor.svelte +52 -26
  40. package/src/component-editor/scaffolding/VariantGroup.svelte +81 -48
  41. package/src/component-editor/scaffolding/componentSectionType.ts +2 -2
  42. package/src/components/Badge.svelte +45 -26
  43. package/src/components/Button.svelte +44 -21
  44. package/src/components/Callout.svelte +17 -12
  45. package/src/components/Card.svelte +23 -11
  46. package/src/components/CollapsibleSection.svelte +56 -27
  47. package/src/components/CornerBadge.svelte +32 -18
  48. package/src/components/Dialog.svelte +55 -31
  49. package/src/components/Image.svelte +14 -5
  50. package/src/components/InlineEditActions.svelte +22 -10
  51. package/src/components/Notification.svelte +39 -19
  52. package/src/components/ProgressBar.svelte +27 -17
  53. package/src/components/RadioButton.svelte +27 -10
  54. package/src/components/SectionDivider.svelte +34 -26
  55. package/src/components/SegmentedControl.svelte +23 -9
  56. package/src/components/TabBar.svelte +23 -10
  57. package/src/components/Table.svelte +8 -3
  58. package/src/components/Tooltip.svelte +15 -5
  59. package/src/lib/ColumnsOverlay.svelte +3 -3
  60. package/src/lib/LiveEditorOverlay.svelte +73 -36
  61. package/src/pages/ComponentEditorPage.svelte +17 -13
  62. package/src/pages/EditorShell.svelte +24 -20
  63. package/src/styles/form-controls.css +2 -2
  64. package/src/styles/tokens.css +59 -81
  65. package/src/ui/BezierCurveEditor.svelte +59 -43
  66. package/src/ui/ColorEditPanel.svelte +71 -44
  67. package/src/ui/EditorViewSwitcher.svelte +9 -5
  68. package/src/ui/FontStackEditor.svelte +16 -15
  69. package/src/ui/GradientEditor.svelte +42 -33
  70. package/src/ui/GradientStopPicker.svelte +18 -29
  71. package/src/ui/PaletteEditor.svelte +238 -212
  72. package/src/ui/PresetFileManager.svelte +20 -18
  73. package/src/ui/ProjectFontsSection.svelte +30 -30
  74. package/src/ui/SurfacesTab.svelte +3 -3
  75. package/src/ui/TextTab.svelte +2 -2
  76. package/src/ui/ThemeFileManager.svelte +38 -35
  77. package/src/ui/Toggle.svelte +11 -9
  78. package/src/ui/UICopyPopover.svelte +19 -15
  79. package/src/ui/UIDialog.svelte +48 -30
  80. package/src/ui/UIFontFamilySelector.svelte +104 -78
  81. package/src/ui/UIFontSizeSelector.svelte +38 -20
  82. package/src/ui/UIFontWeightSelector.svelte +33 -13
  83. package/src/ui/UILineHeightSelector.svelte +33 -13
  84. package/src/ui/UILinkToggle.svelte +7 -6
  85. package/src/ui/UIOptionItem.svelte +21 -7
  86. package/src/ui/UIOptionList.svelte +9 -3
  87. package/src/ui/UIPaddingSelector.svelte +108 -82
  88. package/src/ui/UIPaletteSelector.svelte +186 -161
  89. package/src/ui/UIRadio.svelte +23 -8
  90. package/src/ui/UIRadioGroup.svelte +9 -8
  91. package/src/ui/UIRelinkConfirmPopover.svelte +26 -16
  92. package/src/ui/UITokenSelector.svelte +112 -68
  93. package/src/ui/UIVariantSelector.svelte +79 -57
  94. package/src/ui/VariablesTab.svelte +15 -15
  95. package/src/ui/palette/GradientStopEditor.svelte +45 -26
  96. package/src/ui/palette/OverridesPanel.svelte +85 -49
  97. package/src/ui/palette/PaletteBase.svelte +60 -32
  98. package/src/ui/palette/ScaleCurveEditor.svelte +25 -10
  99. package/src/ui/sections/ColumnsSection.svelte +13 -13
  100. package/src/ui/sections/GradientsSection.svelte +12 -9
  101. package/src/ui/sections/OverlaysSection.svelte +50 -47
  102. package/src/ui/sections/ShadowsSection.svelte +110 -104
  103. package/src/ui/sections/TokenScaleTable.svelte +38 -22
  104. package/src/ui/sections/tokenScales.ts +2 -2
@@ -5,16 +5,17 @@
5
5
  * gradient convention (0deg = pointing up, increasing clockwise) so the
6
6
  * displayed line orients the way the gradient axis will paint.
7
7
  */
8
- import { createEventDispatcher } from 'svelte';
9
-
10
- export let value: number = 0;
11
- export let label: string = 'Angle';
12
- export let size: number = 44;
8
+ interface Props {
9
+ value?: number;
10
+ label?: string;
11
+ size?: number;
12
+ onchange?: (payload: { value: number }) => void;
13
+ }
13
14
 
14
- const dispatch = createEventDispatcher<{ change: { value: number } }>();
15
+ let { value = $bindable(0), label = 'Angle', size = 44, onchange }: Props = $props();
15
16
 
16
- let dialEl: HTMLDivElement;
17
- let dragging = false;
17
+ let dialEl: HTMLDivElement | undefined = $state();
18
+ let dragging = $state(false);
18
19
 
19
20
  function normalize(deg: number): number {
20
21
  const r = Math.round(deg) % 360;
@@ -25,11 +26,11 @@
25
26
  const n = normalize(next);
26
27
  if (n === value) return;
27
28
  value = n;
28
- dispatch('change', { value: n });
29
+ onchange?.({ value: n });
29
30
  }
30
31
 
31
32
  function angleFromEvent(e: PointerEvent): number {
32
- const rect = dialEl.getBoundingClientRect();
33
+ const rect = dialEl!.getBoundingClientRect();
33
34
  const cx = rect.left + rect.width / 2;
34
35
  const cy = rect.top + rect.height / 2;
35
36
  const dx = e.clientX - cx;
@@ -41,7 +42,7 @@
41
42
 
42
43
  function onPointerDown(e: PointerEvent) {
43
44
  dragging = true;
44
- dialEl.setPointerCapture(e.pointerId);
45
+ dialEl!.setPointerCapture(e.pointerId);
45
46
  emit(angleFromEvent(e));
46
47
  }
47
48
  function onPointerMove(e: PointerEvent) {
@@ -51,7 +52,7 @@
51
52
  function onPointerUp(e: PointerEvent) {
52
53
  if (!dragging) return;
53
54
  dragging = false;
54
- dialEl.releasePointerCapture(e.pointerId);
55
+ dialEl!.releasePointerCapture(e.pointerId);
55
56
  }
56
57
 
57
58
  function onInputChange(e: Event) {
@@ -59,7 +60,7 @@
59
60
  if (Number.isFinite(v)) emit(v);
60
61
  }
61
62
 
62
- $: indicatorTransform = `rotate(${value}deg)`;
63
+ let indicatorTransform = $derived(`rotate(${value}deg)`);
63
64
  </script>
64
65
 
65
66
  <div class="angle-dial-row">
@@ -71,17 +72,17 @@
71
72
  class="dial"
72
73
  class:dragging
73
74
  style="width: {size}px; height: {size}px;"
74
- on:pointerdown={onPointerDown}
75
- on:pointermove={onPointerMove}
76
- on:pointerup={onPointerUp}
77
- on:pointercancel={onPointerUp}
75
+ onpointerdown={onPointerDown}
76
+ onpointermove={onPointerMove}
77
+ onpointerup={onPointerUp}
78
+ onpointercancel={onPointerUp}
78
79
  role="slider"
79
80
  aria-valuemin="0"
80
81
  aria-valuemax="360"
81
82
  aria-valuenow={value}
82
83
  aria-label={label}
83
84
  tabindex="0"
84
- on:keydown={(e) => {
85
+ onkeydown={(e) => {
85
86
  if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); emit(value - 1); }
86
87
  else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); emit(value + 1); }
87
88
  }}
@@ -96,7 +97,7 @@
96
97
  max="360"
97
98
  step="1"
98
99
  value={value}
99
- on:change={onInputChange}
100
+ onchange={onInputChange}
100
101
  />
101
102
  <span class="suffix">°</span>
102
103
  </div>
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { run } from 'svelte/legacy';
3
+
2
4
  import DemoHeader from './DemoHeader.svelte';
3
5
  import NonStylableConfig from './NonStylableConfig.svelte';
4
6
  import LinkedBlock from './LinkedBlock.svelte';
@@ -6,36 +8,58 @@
6
8
  import type { Token } from './types';
7
9
  import type { LinkedBlockResult } from './linkedBlock';
8
10
 
9
- export let component: string;
10
- export let title: string;
11
- export let description: string = '';
12
- /** Token list used to drive the reset action in the header. The editor itself
11
+
12
+
13
+
14
+ interface Props {
15
+ component: string;
16
+ title: string;
17
+ description?: string;
18
+ /** Token list used to drive the reset action in the header. The editor itself
13
19
  is responsible for calling `registerComponentSchema` synchronously. */
14
- export let tokens: Token[] = [];
15
- /** Optional linked-block result. When provided, the LinkedBlock is rendered
20
+ tokens?: Token[];
21
+ /** Optional linked-block result. When provided, the LinkedBlock is rendered
16
22
  and hover highlights propagate to VariantGroup children via context. */
17
- export let linked: LinkedBlockResult | null = null;
18
- /** Canonical {value,label} list of variants in display order. When provided
23
+ linked?: LinkedBlockResult | null;
24
+ /** Canonical {value,label} list of variants in display order. When provided
19
25
  with 2+ entries, a single variant tab strip is rendered that drives which
20
26
  VariantGroup is focused. */
21
- export let variants: { value: string; label: string }[] = [];
27
+ variants?: { value: string; label: string }[];
28
+ config?: import('svelte').Snippet;
29
+ children?: import('svelte').Snippet<[any]>;
30
+ }
31
+
32
+ let {
33
+ component,
34
+ title,
35
+ description = '',
36
+ tokens = [],
37
+ linked = null,
38
+ variants = [],
39
+ config,
40
+ children
41
+ }: Props = $props();
22
42
 
23
43
  const ctx = createEditorContext();
24
44
  const { focusedVariant } = ctx;
25
45
 
26
- $: ctx._linkedOrder.set(linked?.linkedOrder ?? null);
27
- $: showVariantTabs = variants.length >= 2;
28
- $: if (showVariantTabs && ($focusedVariant === null || !variants.some((v) => v.value === $focusedVariant))) {
29
- focusedVariant.set(variants[0].value);
30
- }
31
- $: resetVariables = tokens.map((t) => t.variable);
46
+ run(() => {
47
+ ctx._linkedOrder.set(linked?.linkedOrder ?? null);
48
+ });
49
+ let showVariantTabs = $derived(variants.length >= 2);
50
+ run(() => {
51
+ if (showVariantTabs && ($focusedVariant === null || !variants.some((v) => v.value === $focusedVariant))) {
52
+ focusedVariant.set(variants[0].value);
53
+ }
54
+ });
55
+ let resetVariables = $derived(tokens.map((t) => t.variable));
32
56
  </script>
33
57
 
34
58
  <div class="demo-block">
35
59
  <DemoHeader {component} {title} {description} {resetVariables} />
36
- {#if $$slots.config}
60
+ {#if config}
37
61
  <NonStylableConfig>
38
- <slot name="config" />
62
+ {@render config?.()}
39
63
  </NonStylableConfig>
40
64
  {/if}
41
65
  {#if showVariantTabs}
@@ -47,14 +71,14 @@
47
71
  class:active={opt.value === $focusedVariant}
48
72
  role="tab"
49
73
  aria-selected={opt.value === $focusedVariant}
50
- on:click={() => focusedVariant.set(opt.value)}
74
+ onclick={() => focusedVariant.set(opt.value)}
51
75
  >{opt.label}</button>
52
76
  {/each}
53
77
  </div>
54
78
  {/if}
55
- <slot focusedVariant={$focusedVariant} />
79
+ {@render children?.({ focusedVariant: $focusedVariant, })}
56
80
  {#if linked}
57
- <LinkedBlock {component} {linked} on:change />
81
+ <LinkedBlock {component} {linked} />
58
82
  {/if}
59
83
  </div>
60
84
 
@@ -1,4 +1,4 @@
1
- <script context="module" lang="ts">
1
+ <script module lang="ts">
2
2
  declare const __PROJECT_ROOT__: string | undefined;
3
3
  </script>
4
4
 
@@ -27,18 +27,25 @@
27
27
  import ComponentFileMenu from './ComponentFileMenu.svelte';
28
28
  import SaveAsDialog from './SaveAsDialog.svelte';
29
29
 
30
- /** Which component this manager controls (e.g. "button"). */
31
- export let component: string;
32
- /** Display name shown at the start of the bar (e.g. "Segmented Control"). */
33
- export let title: string = '';
34
- /** When provided, renders a Reset button that reverts the component to its
30
+
31
+
32
+
33
+ interface Props {
34
+ /** Which component this manager controls (e.g. "button"). */
35
+ component: string;
36
+ /** Display name shown at the start of the bar (e.g. "Segmented Control"). */
37
+ title?: string;
38
+ /** When provided, renders a Reset button that reverts the component to its
35
39
  currently-loaded config file (discarding unsaved edits). To switch
36
40
  configs or return to default, use the File menu. */
37
- export let resetVariables: string[] | null = null;
41
+ resetVariables?: string[] | null;
42
+ }
43
+
44
+ let { component, title = '', resetVariables = null }: Props = $props();
38
45
 
39
46
  const projectRoot: string =
40
47
  typeof __PROJECT_ROOT__ !== 'undefined' ? (__PROJECT_ROOT__ ?? '') : '';
41
- $: sourceFile = componentSourceFile(component);
48
+ let sourceFile = $derived(componentSourceFile(component));
42
49
 
43
50
  type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
44
51
  let saveStatus: SaveStatus = 'idle';
@@ -49,15 +56,58 @@
49
56
  saveStatus = state;
50
57
  setTimeout(() => (saveStatus = 'idle'), 2000);
51
58
  }
52
- let files: ComponentConfigMeta[] = [];
53
- let activeFileName = 'default';
54
- let currentDisplayName = 'Default';
55
- let saveAsDialog = false;
59
+ let files: ComponentConfigMeta[] = $state([]);
60
+ let activeFileName = $state('default');
61
+ let currentDisplayName = $state('Default');
62
+ let saveAsDialog = $state(false);
56
63
 
57
- let productionInfo: ComponentProductionInfo | null = null;
64
+ let productionInfo = $state<ComponentProductionInfo | null>(null);
58
65
  type ProductionStatus = 'idle' | 'updating' | 'done' | 'error';
59
- let productionUpdateStatus: ProductionStatus = 'idle';
60
- let adoptFeedback = '';
66
+ let productionUpdateStatus: ProductionStatus = $state('idle');
67
+ let adoptFeedback = $state('');
68
+
69
+ let infoOpen = $state(false);
70
+ let infoBtnEl = $state<HTMLButtonElement | undefined>(undefined);
71
+ let infoPopoverEl = $state<HTMLDivElement | undefined>(undefined);
72
+ let infoPopoverReady = $state(false);
73
+
74
+ /** Anchor the fixed-position popover centered below the info button.
75
+ Uses position: fixed so it escapes the sticky header's stacking
76
+ context (which was letting the side panels paint over it). */
77
+ function positionInfoPopover(): void {
78
+ const btn = infoBtnEl;
79
+ const pop = infoPopoverEl;
80
+ if (!btn || !pop) return;
81
+ const br = btn.getBoundingClientRect();
82
+ const pr = pop.getBoundingClientRect();
83
+ const margin = 8;
84
+ let left = br.left + br.width / 2 - pr.width / 2;
85
+ const vw = window.innerWidth;
86
+ if (left < margin) left = margin;
87
+ if (left + pr.width > vw - margin) left = vw - margin - pr.width;
88
+ pop.style.left = `${left}px`;
89
+ pop.style.top = `${br.bottom + margin}px`;
90
+ infoPopoverReady = true;
91
+ }
92
+
93
+ $effect(() => {
94
+ if (!infoOpen) {
95
+ infoPopoverReady = false;
96
+ return;
97
+ }
98
+ // Two rAFs: first so Svelte mounts the popover and the bind: ref is set,
99
+ // second so its rendered width is measurable before we anchor it.
100
+ let raf1 = requestAnimationFrame(() => {
101
+ raf1 = requestAnimationFrame(positionInfoPopover);
102
+ });
103
+ window.addEventListener('scroll', positionInfoPopover, true);
104
+ window.addEventListener('resize', positionInfoPopover);
105
+ return () => {
106
+ cancelAnimationFrame(raf1);
107
+ window.removeEventListener('scroll', positionInfoPopover, true);
108
+ window.removeEventListener('resize', positionInfoPopover);
109
+ };
110
+ });
61
111
 
62
112
  /** Same idle-after-2s pattern for the production-update flash. */
63
113
  function flashProductionStatus(state: Exclude<ProductionStatus, 'idle'>) {
@@ -68,9 +118,9 @@
68
118
  }, 2000);
69
119
  }
70
120
 
71
- $: compDirty = $componentDirty[component] ?? false;
72
- $: isApplied = !!productionInfo && productionInfo.fileName === activeFileName && !compDirty;
73
- $: resetDirty = !!resetVariables && compDirty;
121
+ let compDirty = $derived($componentDirty[component] ?? false);
122
+ let isApplied = $derived(!!productionInfo && productionInfo.fileName === activeFileName && !compDirty);
123
+ let resetDirty = $derived(!!resetVariables && compDirty);
74
124
 
75
125
  async function refreshFiles() {
76
126
  // safeFetch returns null on dev-server unavailable / non-2xx — silently
@@ -99,16 +149,28 @@
99
149
  await refreshFiles();
100
150
  await refreshProduction();
101
151
  window.addEventListener('keydown', handleKeydown);
152
+ document.addEventListener('mousedown', handleDocumentMousedown, true);
102
153
  });
103
154
 
104
155
  onDestroy(() => {
105
156
  window.removeEventListener('keydown', handleKeydown);
157
+ document.removeEventListener('mousedown', handleDocumentMousedown, true);
106
158
  });
107
159
 
108
160
  function handleKeydown(e: KeyboardEvent) {
109
161
  if ((e.metaKey || e.ctrlKey) && e.key === 's') {
110
162
  e.preventDefault();
111
163
  handleSave();
164
+ } else if (e.key === 'Escape' && infoOpen) {
165
+ infoOpen = false;
166
+ }
167
+ }
168
+
169
+ function handleDocumentMousedown(e: MouseEvent) {
170
+ if (!infoOpen) return;
171
+ const target = e.target as Element | null;
172
+ if (target && !target.closest('.cfm-info-btn, .cfm-info-popover')) {
173
+ infoOpen = false;
112
174
  }
113
175
  }
114
176
 
@@ -166,8 +228,8 @@
166
228
  saveAsDialog = true;
167
229
  }
168
230
 
169
- async function confirmSaveAs(e: CustomEvent<{ displayName: string; fileName: string }>) {
170
- const { displayName, fileName } = e.detail;
231
+ async function confirmSaveAs(detail: { displayName: string; fileName: string }) {
232
+ const { displayName, fileName } = detail;
171
233
  saveStatus = 'saving';
172
234
  try {
173
235
  await persist(fileName, displayName);
@@ -178,8 +240,7 @@
178
240
  }
179
241
  }
180
242
 
181
- async function handleLoad(e: CustomEvent<ComponentConfigMeta>) {
182
- const file = e.detail;
243
+ async function handleLoad(file: ComponentConfigMeta) {
183
244
  // Multi-step service flow (load + set-active) — if any network call
184
245
  // fails, the dialog is already closed and the local state stays on the
185
246
  // previous selection. Silent by design; the same boot resilience that
@@ -195,8 +256,7 @@
195
256
  }
196
257
  }
197
258
 
198
- async function handleDelete(e: CustomEvent<ComponentConfigMeta>) {
199
- const file = e.detail;
259
+ async function handleDelete(file: ComponentConfigMeta) {
200
260
  if (file.fileName === 'default') return;
201
261
  // Multi-step service flow (delete + reload-default-on-active-removal).
202
262
  // Silent by design — see handleLoad.
@@ -268,7 +328,7 @@
268
328
  title="Open {sourceFile} in VS Code"
269
329
  >
270
330
  <i class="fas fa-code"></i>
271
- <span>Source</span>
331
+ <span>Show component source</span>
272
332
  </a>
273
333
  {/if}
274
334
  </div>
@@ -300,16 +360,16 @@
300
360
  {component}
301
361
  {files}
302
362
  {activeFileName}
303
- on:save={handleSave}
304
- on:saveAs={openSaveAs}
305
- on:openLoad={refreshFiles}
306
- on:load={handleLoad}
307
- on:delete={handleDelete}
363
+ onsave={handleSave}
364
+ onsaveAs={openSaveAs}
365
+ onopenLoad={refreshFiles}
366
+ onload={handleLoad}
367
+ ondelete={handleDelete}
308
368
  />
309
369
  {#if resetVariables}
310
370
  <button
311
371
  class="cfm-btn reset-btn"
312
- on:click={handleReset}
372
+ onclick={handleReset}
313
373
  disabled={!resetDirty}
314
374
  title="Revert unsaved changes to {currentDisplayName}"
315
375
  >
@@ -342,7 +402,7 @@
342
402
  class:saving={productionUpdateStatus === 'updating'}
343
403
  class:saved={productionUpdateStatus === 'done'}
344
404
  class:error={productionUpdateStatus === 'error'}
345
- on:click={handleUpdateProduction}
405
+ onclick={handleUpdateProduction}
346
406
  disabled={productionUpdateStatus === 'updating' || !productionInfo || (productionInfo.fileName === activeFileName && !compDirty)}
347
407
  title={!productionInfo
348
408
  ? ''
@@ -359,6 +419,50 @@
359
419
  {#if productionUpdateStatus === 'idle'}Adopt{:else if productionUpdateStatus === 'updating'}Adopting{:else if productionUpdateStatus === 'done'}Adopted{:else}Error{/if}
360
420
  </span>
361
421
  </button>
422
+ <button
423
+ type="button"
424
+ class="cfm-info-btn"
425
+ aria-label="About Save and Adopt"
426
+ aria-expanded={infoOpen}
427
+ bind:this={infoBtnEl}
428
+ onclick={() => (infoOpen = !infoOpen)}
429
+ >
430
+ <i class="fas fa-circle-info"></i>
431
+ </button>
432
+ {#if infoOpen}
433
+ <div
434
+ class="cfm-info-popover"
435
+ class:ready={infoPopoverReady}
436
+ role="dialog"
437
+ aria-label="About Save and Adopt"
438
+ bind:this={infoPopoverEl}
439
+ >
440
+ <header class="cfm-info-header">
441
+ <span class="cfm-info-title">Component Configuration</span>
442
+ <button
443
+ type="button"
444
+ class="cfm-info-close"
445
+ aria-label="Close"
446
+ onclick={() => (infoOpen = false)}
447
+ >
448
+ <i class="fas fa-xmark"></i>
449
+ </button>
450
+ </header>
451
+ <div class="cfm-info-body">
452
+ <p>
453
+ Editor and Prod both use a saved file. When they share the
454
+ <em>same</em> file, <strong>Saved changes</strong> go to into production
455
+ immediately. They are sharing the configuration.
456
+ </p>
457
+ <p>
458
+ To experiment without changing production,<strong>Save As</strong> a new file first.
459
+ </p>
460
+ <p>
461
+ When ready, click <strong>Adopt</strong> to use the new file on prod.
462
+ </p>
463
+ </div>
464
+ </div>
465
+ {/if}
362
466
  {#if adoptFeedback}
363
467
  <span class="cfm-feedback" aria-live="polite">{adoptFeedback}</span>
364
468
  {/if}
@@ -371,7 +475,7 @@
371
475
  bind:show={saveAsDialog}
372
476
  {currentDisplayName}
373
477
  {files}
374
- on:save={confirmSaveAs}
478
+ onsave={confirmSaveAs}
375
479
  />
376
480
 
377
481
  <style>
@@ -413,13 +517,15 @@
413
517
  .source-link {
414
518
  display: inline-flex;
415
519
  align-items: center;
416
- gap: var(--ui-space-4);
417
- padding: var(--ui-space-2) var(--ui-space-6);
520
+ gap: var(--ui-space-6);
521
+ height: 26px;
522
+ padding: 0 14px;
418
523
  font-size: var(--ui-font-size-xs);
524
+ font-weight: 500;
419
525
  color: var(--ui-text-secondary);
420
526
  text-decoration: none;
421
527
  border: 1px solid var(--ui-border-default);
422
- border-radius: var(--ui-radius-sm);
528
+ border-radius: 999px;
423
529
  transition: all var(--ui-transition-fast);
424
530
  }
425
531
 
@@ -553,8 +659,10 @@
553
659
  }
554
660
 
555
661
  /* actions cluster — sits directly next to the filename pill so the
556
- buttons stay near the input and don't drift under the open editor panel */
662
+ buttons stay near the input and don't drift under the open editor panel.
663
+ position: relative anchors the info popover below the cluster. */
557
664
  .cfm-actions {
665
+ position: relative;
558
666
  display: flex;
559
667
  align-items: center;
560
668
  gap: var(--ui-space-6);
@@ -674,6 +782,121 @@
674
782
  50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--ui-highlight) 10%, transparent); }
675
783
  }
676
784
 
785
+ /* info button — naked icon, no chrome. The icon itself carries the
786
+ affordance; hover/active simply brighten its color. */
787
+ .cfm-info-btn {
788
+ display: inline-flex;
789
+ align-items: center;
790
+ justify-content: center;
791
+ padding: var(--ui-space-2) var(--ui-space-4);
792
+ background: transparent;
793
+ border: 0;
794
+ color: var(--ui-text-tertiary);
795
+ font-size: 1.15rem;
796
+ line-height: 1;
797
+ cursor: pointer;
798
+ transition: color var(--ui-transition-fast);
799
+ }
800
+
801
+ .cfm-info-btn:hover,
802
+ .cfm-info-btn[aria-expanded='true'] {
803
+ color: var(--ui-text-primary);
804
+ }
805
+
806
+ .cfm-info-popover {
807
+ /* Fixed positioning escapes the sticky header's stacking context,
808
+ so the popover paints over the side panels. JS in this file
809
+ anchors it centered below the info button. */
810
+ position: fixed;
811
+ top: 0;
812
+ left: 0;
813
+ width: 22rem;
814
+ max-width: calc(100vw - var(--ui-space-24));
815
+ padding: 0;
816
+ background: var(--ui-surface-higher);
817
+ border: 1px solid var(--ui-border-medium);
818
+ border-radius: var(--ui-radius-lg);
819
+ box-shadow: var(--ui-shadow-lg);
820
+ z-index: 1000;
821
+ color: var(--ui-text-secondary);
822
+ font-family: var(--ui-font-family, system-ui, sans-serif);
823
+ overflow: hidden;
824
+ visibility: hidden;
825
+ animation: cfm-info-in 140ms ease-out;
826
+ }
827
+
828
+ .cfm-info-popover.ready {
829
+ visibility: visible;
830
+ }
831
+
832
+ .cfm-info-header {
833
+ display: flex;
834
+ align-items: center;
835
+ justify-content: space-between;
836
+ gap: var(--ui-space-8);
837
+ padding: var(--ui-space-10) var(--ui-space-12) var(--ui-space-10) var(--ui-space-16);
838
+ border-bottom: 1px solid var(--ui-border-subtle);
839
+ }
840
+
841
+ .cfm-info-title {
842
+ color: var(--ui-text-primary);
843
+ font-size: var(--ui-font-size-sm);
844
+ font-weight: var(--ui-font-weight-semibold);
845
+ letter-spacing: -0.01em;
846
+ line-height: 1.2;
847
+ }
848
+
849
+ .cfm-info-close {
850
+ display: inline-flex;
851
+ align-items: center;
852
+ justify-content: center;
853
+ width: var(--ui-space-24);
854
+ height: var(--ui-space-24);
855
+ padding: 0;
856
+ background: transparent;
857
+ border: 0;
858
+ border-radius: var(--ui-radius-sm);
859
+ color: var(--ui-text-tertiary);
860
+ font-size: var(--ui-font-size-xs);
861
+ line-height: 1;
862
+ cursor: pointer;
863
+ transition: color var(--ui-transition-fast), background var(--ui-transition-fast);
864
+ }
865
+
866
+ .cfm-info-close:hover {
867
+ color: var(--ui-text-primary);
868
+ background: var(--ui-hover);
869
+ }
870
+
871
+ .cfm-info-body {
872
+ padding: var(--ui-space-16);
873
+ }
874
+
875
+ .cfm-info-popover p {
876
+ margin: 0 0 var(--ui-space-12) 0;
877
+ font-size: var(--ui-font-size-xs);
878
+ line-height: 1.55;
879
+ }
880
+
881
+ .cfm-info-popover p:last-child {
882
+ margin-bottom: 0;
883
+ }
884
+
885
+ .cfm-info-popover strong {
886
+ color: var(--ui-text-primary);
887
+ font-weight: var(--ui-font-weight-semibold);
888
+ }
889
+
890
+ .cfm-info-popover em {
891
+ font-style: italic;
892
+ color: var(--ui-text-primary);
893
+ }
894
+
895
+ @keyframes cfm-info-in {
896
+ from { opacity: 0; transform: translateY(-3px); }
897
+ to { opacity: 1; transform: translateY(0); }
898
+ }
899
+
677
900
  /* narrow viewports: hide button text, keep icons visible */
678
901
  @media (max-width: 640px) {
679
902
  .cfm-btn span { display: none; }