@motion-proto/live-tokens 0.8.0 → 0.9.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.
@@ -21,7 +21,8 @@ import TableEditor, { allTokens as tableTokens } from './TableEditor.svelte';
21
21
  import TabBarEditor, { allTokens as tabBarTokens } from './TabBarEditor.svelte';
22
22
  import TooltipEditor, { allTokens as tooltipTokens } from './TooltipEditor.svelte';
23
23
 
24
- export type ComponentId =
24
+ /** Internal narrowed union of the first-party component ids. Not exposed publicly. */
25
+ type BuiltInComponentId =
25
26
  | 'segmentedcontrol'
26
27
  | 'button'
27
28
  | 'notification'
@@ -41,6 +42,13 @@ export type ComponentId =
41
42
  | 'tooltip'
42
43
  | 'progressbar';
43
44
 
45
+ /**
46
+ * Public component id type. Widened to `string` because consumers can register
47
+ * their own components at runtime via `registerComponent()`. Internal code that
48
+ * needs to narrow to first-party ids can reference `BuiltInComponentId`.
49
+ */
50
+ export type ComponentId = string;
51
+
44
52
  export interface RegistryEntry {
45
53
  /** Canonical id — lowercase, matches the runtime component filename + server scan + `setComponentAlias` key. */
46
54
  id: ComponentId;
@@ -54,21 +62,18 @@ export interface RegistryEntry {
54
62
  editorComponent: Component<any, any, any>;
55
63
  /** Flat token list — the editor's declarative description of its token surface. */
56
64
  schema: Token[];
65
+ /** `'system'` for first-party entries; `'custom'` for entries added via `registerComponent()`. */
66
+ origin: 'system' | 'custom';
57
67
  }
58
68
 
59
69
  /**
60
- * Single source of truth for every component editor. Each entry binds the
61
- * canonical id to its label, icon, source file, editor component, and token
62
- * schema. Display order in the nav rail is sorted alphabetically by label
63
- * (see `componentRegistryEntries` below) — order in this object literal does
64
- * not affect the UI.
65
- *
66
- * Adding a component:
67
- * 1. Author `src/components/<Name>.svelte` (declares CSS vars in `:global(:root)`)
68
- * 2. Author `src/component-editor/<Name>Editor.svelte` (exports `allTokens` from a `<script context="module">` block)
70
+ * First-party registry. Frozen; runtime additions go in `customRegistry`.
71
+ * Adding a first-party component:
72
+ * 1. Author `src/system/components/<Name>.svelte` (declares CSS vars in `:global(:root)`)
73
+ * 2. Author `src/editor/component-editor/<Name>Editor.svelte` (exports `allTokens`)
69
74
  * 3. Add an entry below.
70
75
  */
71
- export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = Object.freeze({
76
+ const builtInRegistry: Readonly<Record<BuiltInComponentId, RegistryEntry>> = Object.freeze({
72
77
  segmentedcontrol: {
73
78
  id: 'segmentedcontrol',
74
79
  label: 'Segmented Control',
@@ -76,6 +81,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
76
81
  sourceFile: 'src/system/components/SegmentedControl.svelte',
77
82
  editorComponent: SegmentedControlEditor,
78
83
  schema: segmentedControlTokens,
84
+ origin: 'system',
79
85
  },
80
86
  button: {
81
87
  id: 'button',
@@ -84,6 +90,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
84
90
  sourceFile: 'src/system/components/Button.svelte',
85
91
  editorComponent: StandardButtonsEditor,
86
92
  schema: buttonTokens,
93
+ origin: 'system',
87
94
  },
88
95
  notification: {
89
96
  id: 'notification',
@@ -92,6 +99,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
92
99
  sourceFile: 'src/system/components/Notification.svelte',
93
100
  editorComponent: NotificationEditor,
94
101
  schema: notificationTokens,
102
+ origin: 'system',
95
103
  },
96
104
  dialog: {
97
105
  id: 'dialog',
@@ -100,6 +108,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
100
108
  sourceFile: 'src/system/components/Dialog.svelte',
101
109
  editorComponent: DialogEditor,
102
110
  schema: dialogTokens,
111
+ origin: 'system',
103
112
  },
104
113
  radiobutton: {
105
114
  id: 'radiobutton',
@@ -108,6 +117,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
108
117
  sourceFile: 'src/system/components/RadioButton.svelte',
109
118
  editorComponent: RadioButtonEditor,
110
119
  schema: radioButtonTokens,
120
+ origin: 'system',
111
121
  },
112
122
  card: {
113
123
  id: 'card',
@@ -116,6 +126,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
116
126
  sourceFile: 'src/system/components/Card.svelte',
117
127
  editorComponent: CardEditor,
118
128
  schema: cardTokens,
129
+ origin: 'system',
119
130
  },
120
131
  badge: {
121
132
  id: 'badge',
@@ -124,6 +135,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
124
135
  sourceFile: 'src/system/components/Badge.svelte',
125
136
  editorComponent: BadgeEditor,
126
137
  schema: badgeTokens,
138
+ origin: 'system',
127
139
  },
128
140
  callout: {
129
141
  id: 'callout',
@@ -132,6 +144,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
132
144
  sourceFile: 'src/system/components/Callout.svelte',
133
145
  editorComponent: CalloutEditor,
134
146
  schema: calloutTokens,
147
+ origin: 'system',
135
148
  },
136
149
  cornerbadge: {
137
150
  id: 'cornerbadge',
@@ -140,6 +153,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
140
153
  sourceFile: 'src/system/components/CornerBadge.svelte',
141
154
  editorComponent: CornerBadgeEditor,
142
155
  schema: cornerBadgeTokens,
156
+ origin: 'system',
143
157
  },
144
158
  image: {
145
159
  id: 'image',
@@ -148,6 +162,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
148
162
  sourceFile: 'src/system/components/Image.svelte',
149
163
  editorComponent: ImageEditor,
150
164
  schema: imageTokens,
165
+ origin: 'system',
151
166
  },
152
167
  inlineeditactions: {
153
168
  id: 'inlineeditactions',
@@ -156,6 +171,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
156
171
  sourceFile: 'src/system/components/InlineEditActions.svelte',
157
172
  editorComponent: InlineEditActionsEditor,
158
173
  schema: inlineEditActionsTokens,
174
+ origin: 'system',
159
175
  },
160
176
  menuselect: {
161
177
  id: 'menuselect',
@@ -164,6 +180,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
164
180
  sourceFile: 'src/system/components/MenuSelect.svelte',
165
181
  editorComponent: MenuSelectEditor,
166
182
  schema: menuSelectTokens,
183
+ origin: 'system',
167
184
  },
168
185
  sectiondivider: {
169
186
  id: 'sectiondivider',
@@ -172,6 +189,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
172
189
  sourceFile: 'src/system/components/SectionDivider.svelte',
173
190
  editorComponent: SectionDividerEditor,
174
191
  schema: sectionDividerTokens,
192
+ origin: 'system',
175
193
  },
176
194
  collapsiblesection: {
177
195
  id: 'collapsiblesection',
@@ -180,6 +198,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
180
198
  sourceFile: 'src/system/components/CollapsibleSection.svelte',
181
199
  editorComponent: CollapsibleSectionEditor,
182
200
  schema: collapsibleSectionTokens,
201
+ origin: 'system',
183
202
  },
184
203
  table: {
185
204
  id: 'table',
@@ -188,6 +207,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
188
207
  sourceFile: 'src/system/components/Table.svelte',
189
208
  editorComponent: TableEditor,
190
209
  schema: tableTokens,
210
+ origin: 'system',
191
211
  },
192
212
  tabbar: {
193
213
  id: 'tabbar',
@@ -196,6 +216,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
196
216
  sourceFile: 'src/system/components/TabBar.svelte',
197
217
  editorComponent: TabBarEditor,
198
218
  schema: tabBarTokens,
219
+ origin: 'system',
199
220
  },
200
221
  tooltip: {
201
222
  id: 'tooltip',
@@ -204,6 +225,7 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
204
225
  sourceFile: 'src/system/components/Tooltip.svelte',
205
226
  editorComponent: TooltipEditor,
206
227
  schema: tooltipTokens,
228
+ origin: 'system',
207
229
  },
208
230
  progressbar: {
209
231
  id: 'progressbar',
@@ -212,34 +234,89 @@ export const componentRegistry: Readonly<Record<ComponentId, RegistryEntry>> = O
212
234
  sourceFile: 'src/system/components/ProgressBar.svelte',
213
235
  editorComponent: ProgressBarEditor,
214
236
  schema: progressBarTokens,
237
+ origin: 'system',
215
238
  },
216
239
  });
217
240
 
218
- /** Display-ordered list of registry entries sorted alphabetically by label. Iteration order matches the nav rail. */
219
- export const componentRegistryEntries: ReadonlyArray<RegistryEntry> = Object.freeze(
220
- Object.values(componentRegistry).sort((a, b) => a.label.localeCompare(b.label)),
221
- );
241
+ /** Mutable map of consumer-registered components, populated by `registerComponent()`. */
242
+ const customRegistry = new Map<string, RegistryEntry>();
243
+
244
+ /** Argument shape for `registerComponent()`. `origin` is set internally to `'custom'`. */
245
+ export type RegisterComponentEntry = Omit<RegistryEntry, 'origin'>;
246
+
247
+ /**
248
+ * Register a consumer-authored component at runtime. Call from `main.ts`
249
+ * before app mount.
250
+ *
251
+ * Collision rule: if `entry.id` matches a built-in id, a warning is logged and
252
+ * the custom entry wins (the custom editor and schema replace the built-in for
253
+ * the rest of the session).
254
+ *
255
+ * Side effect: registers the schema with the editor store so reset-to-default
256
+ * and sibling-group resolution work for the new component.
257
+ */
258
+ export function registerComponent(entry: RegisterComponentEntry): void {
259
+ if (entry.id in builtInRegistry) {
260
+ console.warn(
261
+ `[registerComponent] custom component "${entry.id}" overrides a built-in. The custom editor will be used.`,
262
+ );
263
+ }
264
+ const stored: RegistryEntry = { ...entry, origin: 'custom' };
265
+ customRegistry.set(entry.id, stored);
266
+ registerComponentSchema(entry.id, entry.schema);
267
+ }
268
+
269
+ /**
270
+ * Merged registry: built-ins overlaid with customs (custom wins on id collision).
271
+ * Recomputed on each call so callers see runtime registrations made after their
272
+ * own module-load order.
273
+ */
274
+ export function getComponentRegistry(): Readonly<Record<string, RegistryEntry>> {
275
+ const merged: Record<string, RegistryEntry> = { ...builtInRegistry };
276
+ for (const [id, entry] of customRegistry) {
277
+ merged[id] = entry;
278
+ }
279
+ return merged;
280
+ }
222
281
 
223
- /** All canonical component ids, in display order. */
224
- export const componentIds: ReadonlyArray<ComponentId> = Object.freeze(
225
- componentRegistryEntries.map((e) => e.id),
226
- );
282
+ /**
283
+ * Display-ordered entries: system first (alphabetical by label), then custom
284
+ * (alphabetical by label). Iteration order matches the nav rail's grouping.
285
+ * The nav rail renders a divider between the two groups when customs exist.
286
+ */
287
+ export function getComponentRegistryEntries(): ReadonlyArray<RegistryEntry> {
288
+ const merged = getComponentRegistry();
289
+ const system: RegistryEntry[] = [];
290
+ const custom: RegistryEntry[] = [];
291
+ for (const entry of Object.values(merged)) {
292
+ (entry.origin === 'system' ? system : custom).push(entry);
293
+ }
294
+ system.sort((a, b) => a.label.localeCompare(b.label));
295
+ custom.sort((a, b) => a.label.localeCompare(b.label));
296
+ return [...system, ...custom];
297
+ }
298
+
299
+ /** All component ids, in display order. */
300
+ export function getComponentIds(): ReadonlyArray<string> {
301
+ return getComponentRegistryEntries().map((e) => e.id);
302
+ }
227
303
 
228
- // Eager schema registration. Replaces the side-effect-on-import pattern that
229
- // each editor module previously used (top-of-script `registerComponentSchema(...)`).
230
- // Runs once at module load, before any editor instance mounts.
231
- for (const entry of componentRegistryEntries) {
304
+ // Eager schema registration for built-ins. Customs register lazily inside
305
+ // `registerComponent()` so the store knows about every component before any
306
+ // editor instance mounts.
307
+ for (const entry of Object.values(builtInRegistry)) {
232
308
  registerComponentSchema(entry.id, entry.schema);
233
309
  }
234
310
 
235
311
  /**
236
- * Validate that the server's filesystem scan matches the registry's id list.
312
+ * Validate that the server's filesystem scan matches the merged registry's id list.
237
313
  * Logs a warning when ids drift. Called at boot from the editor page.
238
314
  */
239
315
  export function validateRegistryAgainstServerScan(serverIds: ReadonlyArray<string>): void {
240
- const registrySet = new Set<string>(componentIds);
316
+ const ids = getComponentIds();
317
+ const registrySet = new Set<string>(ids);
241
318
  const serverSet = new Set<string>(serverIds);
242
- const missingOnServer = componentIds.filter((id) => !serverSet.has(id));
319
+ const missingOnServer = ids.filter((id) => !serverSet.has(id));
243
320
  const extraOnServer = serverIds.filter((id) => !registrySet.has(id));
244
321
  if (missingOnServer.length > 0) {
245
322
  console.warn(
@@ -1,13 +1,13 @@
1
1
  <script lang="ts">
2
2
  import type { ComponentSection } from './componentSectionType';
3
- import { defaultSections } from './defaultSections';
3
+ import { getDefaultSections } from './defaultSections';
4
4
 
5
5
  interface Props {
6
6
  sections?: ComponentSection[];
7
7
  selectedComponent?: string;
8
8
  }
9
9
 
10
- let { sections = defaultSections, selectedComponent = sections[0]?.id ?? '' }: Props = $props();
10
+ let { sections = getDefaultSections(), selectedComponent = sections[0]?.id ?? '' }: Props = $props();
11
11
  </script>
12
12
 
13
13
  <div class="components-container">
@@ -1,9 +1,9 @@
1
- import { componentRegistry } from '../registry';
1
+ import { getComponentRegistry } from '../registry';
2
2
 
3
3
  /**
4
4
  * Resolve a component id to its runtime source file path. Reads from the
5
- * single component registry no parallel mapping to maintain.
5
+ * merged component registry (built-ins + runtime registrations).
6
6
  */
7
7
  export function componentSourceFile(component: string): string {
8
- return componentRegistry[component as keyof typeof componentRegistry]?.sourceFile ?? '';
8
+ return getComponentRegistry()[component]?.sourceFile ?? '';
9
9
  }
@@ -1,16 +1,21 @@
1
1
  import type { ComponentSection } from './componentSectionType';
2
- import { componentRegistryEntries } from '../registry';
2
+ import { getComponentRegistryEntries } from '../registry';
3
3
 
4
4
  /**
5
- * Default editor sections — derived from the single component registry. Each
5
+ * Default editor sections — derived from the merged component registry. Each
6
6
  * section's `id` is the canonical lowercase component id (matches the runtime
7
- * filename, server scan, and `setComponentAlias` key); `label` is the
8
- * display string; `component` is the editor Svelte component.
7
+ * filename, server scan, and `setComponentAlias` key); `label` is the display
8
+ * string; `component` is the editor Svelte component.
9
9
  *
10
- * To add or reorder sections, edit `src/component-editor/registry.ts`.
10
+ * Recomputed on each call so consumer-registered components (added via
11
+ * `registerComponent()`) appear after the first-party set in iteration order.
12
+ *
13
+ * To add or reorder first-party sections, edit `src/editor/component-editor/registry.ts`.
11
14
  */
12
- export const defaultSections: ComponentSection[] = componentRegistryEntries.map((entry) => ({
13
- id: entry.id,
14
- label: entry.label,
15
- component: entry.editorComponent,
16
- }));
15
+ export function getDefaultSections(): ComponentSection[] {
16
+ return getComponentRegistryEntries().map((entry) => ({
17
+ id: entry.id,
18
+ label: entry.label,
19
+ component: entry.editorComponent,
20
+ }));
21
+ }
@@ -4,9 +4,11 @@
4
4
  // migration that splits legacy single-bucket aliases into the new
5
5
  // {aliases, config} shape.
6
6
  //
7
- // What goes here: literal-valued knobs that don't translate to CSS vars
8
- // (e.g. Dialog's confirm/cancel variant string is consumed by Dialog.svelte
9
- // via `$editorState`, not via CSS cascade).
7
+ // What goes here: literal-valued knobs that live in the config bucket rather
8
+ // than the alias bucket. Some are runtime CSS values consumed by live
9
+ // components via the cascade (see CASCADING_COMPONENT_CONFIG_KEYS below);
10
+ // others are editor-only metadata that drive alias rewrites without ever
11
+ // reaching :root.
10
12
  //
11
13
  // What does NOT go here: aliases whose values are themselves CSS-var refs
12
14
  // — even if the value space is constrained (e.g. `--button-shimmer` →
@@ -25,3 +27,12 @@ export const KNOWN_COMPONENT_CONFIG_KEYS: ReadonlySet<string> = new Set([
25
27
  '--sectiondivider-md-color-family',
26
28
  '--sectiondivider-sm-color-family',
27
29
  ]);
30
+
31
+ // Subset of KNOWN_COMPONENT_CONFIG_KEYS that the renderer emits to :root as
32
+ // CSS vars so live components can read them via the cascade. Editor-only
33
+ // metadata (e.g. `--sectiondivider-*-color-family`, which drives an alias
34
+ // rewrite rather than a runtime value) is intentionally excluded.
35
+ export const CASCADING_COMPONENT_CONFIG_KEYS: ReadonlySet<string> = new Set([
36
+ '--dialog-confirm-variant',
37
+ '--dialog-cancel-variant',
38
+ ]);
@@ -33,6 +33,7 @@ import { writable, derived, get, type Readable } from 'svelte/store';
33
33
  import type { CssVarRef, EditorState } from '../../store/editorTypes';
34
34
  import { store, mutate } from '../../store/editorCore';
35
35
  import { formatGradientValue } from './gradients';
36
+ import { CASCADING_COMPONENT_CONFIG_KEYS } from '../../components/componentConfigKeys';
36
37
 
37
38
  const EMPTY_COMPONENT_BASELINE = JSON.stringify({ aliases: {}, config: {} });
38
39
 
@@ -48,6 +49,11 @@ export function componentsToVars(components: EditorState['components']): Record<
48
49
  else if (ref.kind === 'literal') out[varName] = ref.value;
49
50
  else out[varName] = formatGradientValue(ref.value);
50
51
  }
52
+ for (const [key, value] of Object.entries(slice.config)) {
53
+ if (CASCADING_COMPONENT_CONFIG_KEYS.has(key) && typeof value === 'string') {
54
+ out[key] = value;
55
+ }
56
+ }
51
57
  }
52
58
  return out;
53
59
  }
@@ -56,6 +62,9 @@ export function getComponentOwnedVarNames(state: EditorState): string[] {
56
62
  const names: string[] = [];
57
63
  for (const slice of Object.values(state.components)) {
58
64
  for (const name of Object.keys(slice.aliases)) names.push(name);
65
+ for (const key of Object.keys(slice.config)) {
66
+ if (CASCADING_COMPONENT_CONFIG_KEYS.has(key)) names.push(key);
67
+ }
59
68
  }
60
69
  return names;
61
70
  }
@@ -7,7 +7,13 @@ export { configureEditor, storageKey } from './core/store/editorConfig';
7
7
  export { activeFileName } from './core/store/editorConfigStore';
8
8
  export { init as initRouter, route, navigate } from './core/routing/router';
9
9
  export { init as initCssVarSync } from './core/cssVarSync';
10
- export { init as initEditorStore } from './core/store/editorStore';
10
+ export {
11
+ init as initEditorStore,
12
+ editorState,
13
+ setComponentAlias,
14
+ setComponentConfig,
15
+ registerComponentSchema,
16
+ } from './core/store/editorStore';
11
17
 
12
18
  export { setCssVar, removeCssVar } from './core/cssVarSync';
13
19
 
@@ -67,3 +73,6 @@ export { hexToOklch, oklchToHex, gamutClamp } from './core/palettes/oklch';
67
73
  export type { Oklch } from './core/palettes/oklch';
68
74
 
69
75
  export { initializeTheme } from './core/themes/themeInit';
76
+
77
+ export { registerComponent } from './component-editor/registry';
78
+ export type { RegisterComponentEntry, RegistryEntry, ComponentId } from './component-editor/registry';
@@ -5,7 +5,7 @@
5
5
  import ComponentsTab from '../component-editor/scaffolding/ComponentsTab.svelte';
6
6
  import ManifestFileManager from '../ui/ManifestFileManager.svelte';
7
7
  import { navigate } from '../core/routing/router';
8
- import { componentRegistryEntries, validateRegistryAgainstServerScan } from '../component-editor/registry';
8
+ import { getComponentRegistryEntries, validateRegistryAgainstServerScan } from '../component-editor/registry';
9
9
  import { listComponents } from '../core/components/componentConfigService';
10
10
  import { selectedComponent } from '../core/store/editorViewStore';
11
11
  import { componentDirty } from '../core/store/editorStore';
@@ -96,7 +96,9 @@
96
96
  window.removeEventListener('keydown', handleKeydown);
97
97
  });
98
98
 
99
- const componentNavItems = componentRegistryEntries.map(({ id, label, icon }) => ({ id, label, icon }));
99
+ const allComponentNavItems = getComponentRegistryEntries().map(({ id, label, icon, origin }) => ({ id, label, icon, origin }));
100
+ const systemNavItems = allComponentNavItems.filter((i) => i.origin === 'system');
101
+ const customNavItems = allComponentNavItems.filter((i) => i.origin === 'custom');
100
102
  </script>
101
103
 
102
104
  <!--
@@ -146,7 +148,7 @@
146
148
  {/if}
147
149
  </div>
148
150
  <div class="nav-items">
149
- {#each componentNavItems as item}
151
+ {#each systemNavItems as item}
150
152
  <button
151
153
  class="nav-item"
152
154
  class:active={$selectedComponent === item.id}
@@ -162,6 +164,27 @@
162
164
  {/if}
163
165
  </button>
164
166
  {/each}
167
+ {#if customNavItems.length > 0}
168
+ <div class="nav-divider">
169
+ <span class="nav-divider-label">Custom</span>
170
+ </div>
171
+ {#each customNavItems as item}
172
+ <button
173
+ class="nav-item"
174
+ class:active={$selectedComponent === item.id}
175
+ class:dirty={$componentDirty[item.id]}
176
+ onmouseenter={(e) => showHint(item.label, e.currentTarget)}
177
+ onmouseleave={hideHint}
178
+ onclick={() => selectComponent(item.id)}
179
+ >
180
+ <i class={item.icon}></i>
181
+ <span class="rail-label">{item.label}</span>
182
+ {#if $componentDirty[item.id]}
183
+ <span class="dirty-dot" aria-label="Unsaved changes" title="Unsaved changes"></span>
184
+ {/if}
185
+ </button>
186
+ {/each}
187
+ {/if}
165
188
  </div>
166
189
  {#if drawerOpen}
167
190
  <div class="sidebar-footer">
@@ -331,6 +354,33 @@
331
354
  background: black;
332
355
  }
333
356
 
357
+ /* Divider between SYSTEM and CUSTOM groups. The horizontal line uses the
358
+ dimmer border token (sub-element separator), with an uppercase eyebrow
359
+ label that fades out when the rail is collapsed so the line still reads. */
360
+ .nav-divider {
361
+ display: grid;
362
+ grid-template-columns: 48px 1fr;
363
+ align-items: center;
364
+ height: 28px;
365
+ margin-top: var(--ui-space-8);
366
+ border-top: 1px solid var(--ui-border-low);
367
+ }
368
+
369
+ .nav-divider-label {
370
+ grid-column: 2;
371
+ font-size: var(--ui-font-size-xs);
372
+ font-weight: var(--ui-font-weight-semibold);
373
+ color: var(--ui-text-tertiary);
374
+ text-transform: uppercase;
375
+ letter-spacing: 0.04em;
376
+ opacity: 0;
377
+ transition: opacity 180ms ease;
378
+ }
379
+
380
+ .components-shell.rail-expanded .nav-divider-label {
381
+ opacity: 1;
382
+ }
383
+
334
384
  .sidebar-footer {
335
385
  flex-shrink: 0;
336
386
  margin-top: auto;
@@ -13,7 +13,7 @@
13
13
  import { editorState } from '../core/store/editorStore';
14
14
  import { editorView, sidebarCondensed, selectedComponent } from '../core/store/editorViewStore';
15
15
  import { componentDirty } from '../core/store/editorStore';
16
- import { componentRegistryEntries, validateRegistryAgainstServerScan } from '../component-editor/registry';
16
+ import { getComponentRegistryEntries, validateRegistryAgainstServerScan } from '../component-editor/registry';
17
17
  import { listComponents } from '../core/components/componentConfigService';
18
18
 
19
19
  const tokenNavItems = [
@@ -29,7 +29,9 @@
29
29
  { id: 'utility-tokens', label: 'Utility Tokens', icon: 'fas fa-sliders' }
30
30
  ];
31
31
 
32
- const componentNavItems = componentRegistryEntries.map(({ id, label, icon }) => ({ id, label, icon }));
32
+ const allComponentNavItems = getComponentRegistryEntries().map(({ id, label, icon, origin }) => ({ id, label, icon, origin }));
33
+ const systemNavItems = allComponentNavItems.filter((i) => i.origin === 'system');
34
+ const customNavItems = allComponentNavItems.filter((i) => i.origin === 'custom');
33
35
 
34
36
  let selectedTokenSection: string | null = $state(null);
35
37
  let saveStatus: 'idle' | 'saving' | 'saved' | 'error' = $state('idle');
@@ -149,7 +151,7 @@
149
151
  {/if}
150
152
  {:else}
151
153
  <div class="nav-items">
152
- {#each componentNavItems as item}
154
+ {#each systemNavItems as item}
153
155
  <button
154
156
  class="nav-item"
155
157
  class:active={$selectedComponent === item.id}
@@ -165,6 +167,27 @@
165
167
  {/if}
166
168
  </button>
167
169
  {/each}
170
+ {#if customNavItems.length > 0}
171
+ <div class="nav-divider">
172
+ <span class="nav-divider-label">Custom</span>
173
+ </div>
174
+ {#each customNavItems as item}
175
+ <button
176
+ class="nav-item"
177
+ class:active={$selectedComponent === item.id}
178
+ class:dirty={$componentDirty[item.id]}
179
+ onmouseenter={(e) => showHint(item.label, e.currentTarget)}
180
+ onmouseleave={hideHint}
181
+ onclick={() => selectComponent(item.id)}
182
+ >
183
+ <i class={item.icon}></i>
184
+ <span class="nav-label">{item.label}</span>
185
+ {#if $componentDirty[item.id]}
186
+ <span class="dirty-dot" aria-label="Unsaved changes" title="Unsaved changes"></span>
187
+ {/if}
188
+ </button>
189
+ {/each}
190
+ {/if}
168
191
  </div>
169
192
  {#if !condensed}
170
193
  <div class="sidebar-footer">
@@ -248,6 +271,33 @@
248
271
  flex-shrink: 0;
249
272
  }
250
273
 
274
+ /* Divider between SYSTEM and CUSTOM groups. The horizontal line uses the
275
+ dimmer border token (sub-element separator), with an uppercase eyebrow
276
+ label that fades out when the rail is condensed so the line still reads. */
277
+ .nav-divider {
278
+ display: grid;
279
+ grid-template-columns: 48px 1fr;
280
+ align-items: center;
281
+ height: 28px;
282
+ margin-top: var(--ui-space-8);
283
+ border-top: 1px solid var(--ui-border-low);
284
+ }
285
+
286
+ .nav-divider-label {
287
+ grid-column: 2;
288
+ font-size: var(--ui-font-size-xs);
289
+ font-weight: var(--ui-font-weight-semibold);
290
+ color: var(--ui-text-tertiary);
291
+ text-transform: uppercase;
292
+ letter-spacing: 0.04em;
293
+ opacity: 1;
294
+ transition: opacity 180ms ease;
295
+ }
296
+
297
+ .layout.condensed .nav-divider-label {
298
+ opacity: 0;
299
+ }
300
+
251
301
  .nav-item {
252
302
  position: relative;
253
303
  display: grid;
@@ -2,7 +2,6 @@
2
2
  import { createEventDispatcher, tick } from 'svelte';
3
3
  import type { Snippet } from 'svelte';
4
4
  import Button from './Button.svelte';
5
- import { editorState } from '../../editor/core/store/editorStore';
6
5
  import type { ButtonVariant, DialogButtonSpec } from './types';
7
6
 
8
7
  const BUTTON_VARIANTS: readonly ButtonVariant[] = ['primary', 'secondary', 'outline', 'success', 'danger', 'warning'];
@@ -10,6 +9,18 @@
10
9
  return v && (BUTTON_VARIANTS as readonly string[]).includes(v) ? (v as ButtonVariant) : fallback;
11
10
  }
12
11
 
12
+ // Read the configured Button variants from :root. The editor mutates these
13
+ // inline on documentElement via cssVarSync; a MutationObserver picks the
14
+ // changes up without coupling this component to the editor module graph.
15
+ // In production the var lives in the generated stylesheet and never
16
+ // changes, so the observer registers but never fires.
17
+ function readCssVar(name: string): string {
18
+ if (typeof document === 'undefined') return '';
19
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
20
+ }
21
+ let confirmVarValue = $state(readCssVar('--dialog-confirm-variant'));
22
+ let cancelVarValue = $state(readCssVar('--dialog-cancel-variant'));
23
+
13
24
  interface Props {
14
25
  show?: boolean;
15
26
  title?: string;
@@ -39,9 +50,8 @@
39
50
  children,
40
51
  }: Props = $props();
41
52
 
42
- let configuredConfig = $derived($editorState.components.dialog?.config ?? {});
43
- let effectiveConfirmVariant = $derived(confirm?.variant ?? asVariant(configuredConfig['--dialog-confirm-variant'] as string | undefined, 'primary'));
44
- let effectiveCancelVariant = $derived(cancel?.variant ?? asVariant(configuredConfig['--dialog-cancel-variant'] as string | undefined, 'outline'));
53
+ let effectiveConfirmVariant = $derived(confirm?.variant ?? asVariant(confirmVarValue, 'primary'));
54
+ let effectiveCancelVariant = $derived(cancel?.variant ?? asVariant(cancelVarValue, 'outline'));
45
55
 
46
56
  // Dual-fire bridge — see Button.svelte for the deprecation timeline.
47
57
  const dispatch = createEventDispatcher<{
@@ -52,6 +62,16 @@
52
62
  let cancelButtonRef: HTMLButtonElement = $state()!;
53
63
  let closeButtonRef: HTMLButtonElement = $state()!;
54
64
 
65
+ $effect(() => {
66
+ if (typeof document === 'undefined') return;
67
+ const obs = new MutationObserver(() => {
68
+ confirmVarValue = readCssVar('--dialog-confirm-variant');
69
+ cancelVarValue = readCssVar('--dialog-cancel-variant');
70
+ });
71
+ obs.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
72
+ return () => obs.disconnect();
73
+ });
74
+
55
75
  // Focus the primary button when dialog opens (skip in inline mode so the editor doesn't steal focus).
56
76
  $effect(() => {
57
77
  if (show && !inline) {