@motion-proto/live-tokens 0.14.1 → 0.16.1

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.
package/README.md CHANGED
@@ -41,7 +41,7 @@ export default defineConfig({
41
41
 
42
42
  The `themeFileApi` plugin:
43
43
  - Seeds `src/live-tokens/data/themes/` with a default theme on first dev-server start.
44
- - Discovers components at `src/system/components/*.svelte` and seeds `src/live-tokens/data/component-configs/{comp}/default.json` from each component's `:global(:root)` block.
44
+ - Discovers components at `src/components/*.svelte` (and `src/system/components/*.svelte` for back-compat) and seeds `src/live-tokens/data/component-configs/{comp}/default.json` from each component's `:global(:root)` block.
45
45
  - Hosts the `/api/live-tokens/*` routes the editor uses to save and load themes + per-component configs.
46
46
  - Auto-injects `__PROJECT_ROOT__` for the overlay's "Page Source" link and `__LIVE_TOKENS_API_BASE__` so the client uses whatever `apiBase` you configured.
47
47
 
@@ -73,66 +73,79 @@ Resolution order, per folder: explicit `themeFileApi(opts)` argument > matching
73
73
  ### Bootstrap in `main.ts`
74
74
 
75
75
  ```ts
76
- import './styles/tokens.css';
77
- import {
78
- configureEditor,
79
- initializeTheme,
80
- initCssVarSync,
81
- initRouter,
82
- initColumnsOverlay,
83
- initEditorStore,
84
- } from '@motion-proto/live-tokens';
76
+ // main.ts
77
+ import '@motion-proto/live-tokens/app/tokens.css';
78
+ import './live-tokens/data/tokens.generated.css';
79
+ import '@motion-proto/live-tokens/app/fonts.css';
80
+ import { bootLiveTokens } from '@motion-proto/live-tokens';
85
81
  import App from './App.svelte';
86
- import { mount } from 'svelte';
87
82
 
88
- configureEditor({ storagePrefix: 'my-app-' });
89
-
90
- async function boot() {
91
- initCssVarSync();
92
- initRouter();
93
- initColumnsOverlay();
94
- initEditorStore();
95
- if (import.meta.env.DEV) {
96
- await initializeTheme();
97
- }
98
- mount(App, { target: document.getElementById('app')! });
99
- }
83
+ bootLiveTokens(App, '#app');
84
+ ```
85
+
86
+ `bootLiveTokens` orchestrates the editor's idempotent init hooks, fetches the
87
+ active theme in dev, optionally registers consumer-authored components, and
88
+ mounts the app. FontAwesome is side-effect-imported by the bootstrap (the dev
89
+ overlay always needs icons). The three token-CSS imports stay with the
90
+ consumer because order matters and `tokens.generated.css` is project-local.
91
+
92
+ Pass `components` to register consumer-authored editable components:
100
93
 
101
- boot();
94
+ ```ts
95
+ import MyWidgetEditor, { allTokens as myWidgetTokens } from './components/MyWidgetEditor.svelte';
96
+
97
+ bootLiveTokens(App, '#app', {
98
+ components: [{
99
+ id: 'mywidget',
100
+ label: 'My Widget',
101
+ icon: 'fas fa-magic',
102
+ sourceFile: 'src/components/MyWidget.svelte',
103
+ editorComponent: MyWidgetEditor,
104
+ schema: myWidgetTokens,
105
+ }],
106
+ });
102
107
  ```
103
108
 
104
- ### Mount overlay + editor pages
109
+ ### Mount routes with `<LiveTokensRouter>`
105
110
 
106
111
  ```svelte
107
112
  <!-- App.svelte -->
108
113
  <script lang="ts">
109
- import { LiveEditorOverlay, ColumnsOverlay } from '@motion-proto/live-tokens';
110
- import Editor from '@motion-proto/live-tokens/editor';
111
- import ComponentEditorPage from '@motion-proto/live-tokens/component-editor-page';
112
- import { route } from './router';
114
+ import { LiveTokensRouter } from '@motion-proto/live-tokens';
113
115
  </script>
114
116
 
115
- <LiveEditorOverlay
116
- navLinks={[
117
- { path: '/', label: 'Home', icon: 'fa-home' },
118
- { path: '/components', label: 'Components', icon: 'fa-puzzle-piece' },
119
- ]}
120
- pageSources={{
121
- '/': 'src/app/Home.svelte',
122
- }}
123
- />
124
- <ColumnsOverlay />
125
-
126
- {#if $route === '/editor'}
127
- <Editor />
128
- {:else if $route === '/components'}
129
- <ComponentEditorPage />
130
- {:else}
131
- <!-- your routes -->
132
- {/if}
117
+ <LiveTokensRouter pages={{
118
+ '/': { lazy: () => import('./Home.svelte'), label: 'Home', icon: 'fa-home', source: 'src/Home.svelte' },
119
+ }} />
133
120
  ```
134
121
 
135
- `<LiveEditorOverlay />` self-gates: it only renders in dev, never inside an iframe, and never on the editor route. No need to wrap in `{#if import.meta.env.DEV}` guards.
122
+ `<LiveTokensRouter>` owns the dev overlay (`<LiveEditorOverlay>` +
123
+ `<ColumnsOverlay>`), the editor routes (`/editor`, `/components`), the
124
+ in-app link-click interception, and the nav-rail/page-source plumbing the
125
+ overlay needs. Each entry in `pages` is one of your routes; entries with a
126
+ `label` appear in the overlay's nav rail. Pass pages as `lazy: () => import('./Page.svelte')`
127
+ so each page's stylesheet side-effects only evaluate when that route is
128
+ visited; pass `component: PageComponent` instead for an eagerly-imported
129
+ page. The editor routes are dispatched internally, so you don't have to
130
+ dynamic-import the library's editor pages yourself.
131
+
132
+ You can also relocate or disable a default editor route via the
133
+ `editorRoutes` prop: `<LiveTokensRouter pages={…} editorRoutes={{ editor: '/admin/editor', components: false }} />`.
134
+ Pass a string to move a route; pass `false` to remove the route entirely
135
+ (no dispatch and, for `components`, no auto-injected nav-rail entry).
136
+
137
+ The whole overlay surface is dev-only and tree-shakes out of production
138
+ builds — no `{#if import.meta.env.DEV}` guards needed.
139
+
140
+ ### Lower-level API (when you need it)
141
+
142
+ `bootLiveTokens` and `<LiveTokensRouter>` are convenience wrappers. The
143
+ individual init functions (`initCssVarSync`, `initRouter`,
144
+ `initColumnsOverlay`, `initEditorStore`, `initializeTheme`),
145
+ `<LiveEditorOverlay>`, `<ColumnsOverlay>`, and the editor page exports
146
+ (`@motion-proto/live-tokens/editor`,
147
+ `@motion-proto/live-tokens/component-editor-page`) all stay exported. Use
148
+ them directly if you need a custom shell or non-standard route dispatch.
136
149
 
137
150
  ### Use components
138
151
 
@@ -199,7 +212,7 @@ export default defineConfig({
199
212
  });
200
213
  ```
201
214
 
202
- No `css: 'injected'` workaround, no `optimizeDeps` excludes — `vite build` works as-is. (You'll want the full `themeFileApi` plugin from the Quick install section above when you're ready to persist edits to disk.)
215
+ No `css: 'injected'` workaround, no `optimizeDeps` excludes — `vite build` works as-is. (You'll want the full `themeFileApi` plugin and `bootLiveTokens` / `<LiveTokensRouter>` from the Quick install section above when you're ready to persist edits to disk and ship a real app.)
203
216
 
204
217
  ## Greenfield? Use the starter
205
218
 
@@ -216,25 +229,28 @@ Open http://localhost:5173 and replace `src/app/Home.svelte` with your content.
216
229
 
217
230
  ## Consumer-authored components
218
231
 
219
- The shipped components are first-party by default, but you can author your own and get the same real-time editing experience via `registerComponent()`. Co-locate runtime and editor files in `src/system/components/` and register the pair before mounting:
232
+ The shipped components are first-party by default, but you can author your own and get the same real-time editing experience. Co-locate runtime and editor files in `src/components/` (or `src/system/components/`, both are scanned by default) and pass them to `bootLiveTokens`:
220
233
 
221
234
  ```ts
222
235
  // src/main.ts
223
- import { registerComponent } from '@motion-proto/live-tokens';
224
- import MyWidgetEditor, { allTokens as myWidgetTokens } from './system/components/MyWidgetEditor.svelte';
225
-
226
- registerComponent({
227
- id: 'mywidget',
228
- label: 'My Widget',
229
- icon: 'fas fa-magic',
230
- sourceFile: 'src/system/components/MyWidget.svelte',
231
- editorComponent: MyWidgetEditor,
232
- schema: myWidgetTokens,
236
+ import { bootLiveTokens } from '@motion-proto/live-tokens';
237
+ import App from './App.svelte';
238
+ import MyWidgetEditor, { allTokens as myWidgetTokens } from './components/MyWidgetEditor.svelte';
239
+
240
+ bootLiveTokens(App, '#app', {
241
+ components: [{
242
+ id: 'mywidget',
243
+ label: 'My Widget',
244
+ icon: 'fas fa-magic',
245
+ sourceFile: 'src/components/MyWidget.svelte',
246
+ editorComponent: MyWidgetEditor,
247
+ schema: myWidgetTokens,
248
+ }],
233
249
  });
234
-
235
- // then mount(App, ...)
236
250
  ```
237
251
 
252
+ (`bootLiveTokens` calls `registerComponent` internally for each entry, gated on `import.meta.env.DEV` so the registration tree-shakes out of production builds. Call `registerComponent` directly if you need finer control over timing.)
253
+
238
254
  The component appears in the `/components` page under a **CUSTOM** group in the nav rail. Token rows, linked-block sharing, per-component config persistence, and reset-to-default work identically to the built-in set. All imports must come from `@motion-proto/live-tokens` or `@motion-proto/live-tokens/component-editor`; never deep-import from `src/`.
239
255
 
240
256
  ## Claude Code skills
@@ -635,7 +635,7 @@ function themeFileApi(opts) {
635
635
  const GENERATED_CSS_PATH = opts.tokensGeneratedCssPath ? import_path3.default.resolve(opts.tokensGeneratedCssPath) : import_path3.default.join(dataDirs.dataDir, "tokens.generated.css");
636
636
  const FONTS_CSS_PATH = opts.fontsCssPath ? import_path3.default.resolve(opts.fontsCssPath) : import_path3.default.join(import_path3.default.dirname(CSS_PATH), "fonts.css");
637
637
  const API_BASE = opts.apiBase ?? "/api/live-tokens";
638
- const consumerComponentsDir = opts.componentsSrcDir ? import_path3.default.resolve(opts.componentsSrcDir) : import_path3.default.resolve("src/system/components");
638
+ const consumerComponentDirs = opts.componentsSrcDir ? [import_path3.default.resolve(opts.componentsSrcDir)] : [import_path3.default.resolve("src/components"), import_path3.default.resolve("src/system/components")];
639
639
  const packageComponentsDir = import_path3.default.resolve(
640
640
  import_path3.default.dirname((0, import_node_url.fileURLToPath)(import_meta.url)),
641
641
  "..",
@@ -643,8 +643,8 @@ function themeFileApi(opts) {
643
643
  "system",
644
644
  "components"
645
645
  );
646
- const COMPONENTS_SCAN_DIRS = [consumerComponentsDir];
647
- if (packageComponentsDir !== consumerComponentsDir && import_fs3.default.existsSync(packageComponentsDir)) {
646
+ const COMPONENTS_SCAN_DIRS = [...consumerComponentDirs];
647
+ if (!COMPONENTS_SCAN_DIRS.includes(packageComponentsDir) && import_fs3.default.existsSync(packageComponentsDir)) {
648
648
  COMPONENTS_SCAN_DIRS.push(packageComponentsDir);
649
649
  }
650
650
  const LEGACY_PRESETS_DIR = import_path3.default.resolve("presets");
@@ -846,14 +846,24 @@ function themeFileApi(opts) {
846
846
  function componentNameFromFile(filePath) {
847
847
  return import_path3.default.basename(filePath, ".svelte").toLowerCase();
848
848
  }
849
+ function isThemeAwareComponent(filePath) {
850
+ try {
851
+ const source = import_fs3.default.readFileSync(filePath, "utf-8");
852
+ return /:global\(:root\)\s*\{/.test(source);
853
+ } catch {
854
+ return false;
855
+ }
856
+ }
849
857
  function listComponentSourcePaths() {
850
858
  const byName = /* @__PURE__ */ new Map();
851
859
  for (const dir of COMPONENTS_SCAN_DIRS) {
852
860
  if (!import_fs3.default.existsSync(dir)) continue;
853
861
  for (const f of import_fs3.default.readdirSync(dir)) {
854
862
  if (!f.endsWith(".svelte")) continue;
863
+ const full = import_path3.default.join(dir, f);
864
+ if (!isThemeAwareComponent(full)) continue;
855
865
  const name = componentNameFromFile(f);
856
- if (!byName.has(name)) byName.set(name, import_path3.default.join(dir, f));
866
+ if (!byName.has(name)) byName.set(name, full);
857
867
  }
858
868
  }
859
869
  return Array.from(byName.values());
@@ -1695,6 +1705,7 @@ function themeFileApi(opts) {
1695
1705
  const normalized = import_path3.default.resolve(ctx.file);
1696
1706
  if (!COMPONENTS_SCAN_DIRS.some((d) => normalized.startsWith(d))) return;
1697
1707
  if (!normalized.endsWith(".svelte")) return;
1708
+ if (!isThemeAwareComponent(normalized)) return;
1698
1709
  const comp = componentNameFromFile(normalized);
1699
1710
  generateDefaultConfig(comp, normalized);
1700
1711
  }
@@ -598,7 +598,7 @@ function themeFileApi(opts) {
598
598
  const GENERATED_CSS_PATH = opts.tokensGeneratedCssPath ? path3.resolve(opts.tokensGeneratedCssPath) : path3.join(dataDirs.dataDir, "tokens.generated.css");
599
599
  const FONTS_CSS_PATH = opts.fontsCssPath ? path3.resolve(opts.fontsCssPath) : path3.join(path3.dirname(CSS_PATH), "fonts.css");
600
600
  const API_BASE = opts.apiBase ?? "/api/live-tokens";
601
- const consumerComponentsDir = opts.componentsSrcDir ? path3.resolve(opts.componentsSrcDir) : path3.resolve("src/system/components");
601
+ const consumerComponentDirs = opts.componentsSrcDir ? [path3.resolve(opts.componentsSrcDir)] : [path3.resolve("src/components"), path3.resolve("src/system/components")];
602
602
  const packageComponentsDir = path3.resolve(
603
603
  path3.dirname(fileURLToPath(import.meta.url)),
604
604
  "..",
@@ -606,8 +606,8 @@ function themeFileApi(opts) {
606
606
  "system",
607
607
  "components"
608
608
  );
609
- const COMPONENTS_SCAN_DIRS = [consumerComponentsDir];
610
- if (packageComponentsDir !== consumerComponentsDir && fs3.existsSync(packageComponentsDir)) {
609
+ const COMPONENTS_SCAN_DIRS = [...consumerComponentDirs];
610
+ if (!COMPONENTS_SCAN_DIRS.includes(packageComponentsDir) && fs3.existsSync(packageComponentsDir)) {
611
611
  COMPONENTS_SCAN_DIRS.push(packageComponentsDir);
612
612
  }
613
613
  const LEGACY_PRESETS_DIR = path3.resolve("presets");
@@ -809,14 +809,24 @@ function themeFileApi(opts) {
809
809
  function componentNameFromFile(filePath) {
810
810
  return path3.basename(filePath, ".svelte").toLowerCase();
811
811
  }
812
+ function isThemeAwareComponent(filePath) {
813
+ try {
814
+ const source = fs3.readFileSync(filePath, "utf-8");
815
+ return /:global\(:root\)\s*\{/.test(source);
816
+ } catch {
817
+ return false;
818
+ }
819
+ }
812
820
  function listComponentSourcePaths() {
813
821
  const byName = /* @__PURE__ */ new Map();
814
822
  for (const dir of COMPONENTS_SCAN_DIRS) {
815
823
  if (!fs3.existsSync(dir)) continue;
816
824
  for (const f of fs3.readdirSync(dir)) {
817
825
  if (!f.endsWith(".svelte")) continue;
826
+ const full = path3.join(dir, f);
827
+ if (!isThemeAwareComponent(full)) continue;
818
828
  const name = componentNameFromFile(f);
819
- if (!byName.has(name)) byName.set(name, path3.join(dir, f));
829
+ if (!byName.has(name)) byName.set(name, full);
820
830
  }
821
831
  }
822
832
  return Array.from(byName.values());
@@ -1658,6 +1668,7 @@ function themeFileApi(opts) {
1658
1668
  const normalized = path3.resolve(ctx.file);
1659
1669
  if (!COMPONENTS_SCAN_DIRS.some((d) => normalized.startsWith(d))) return;
1660
1670
  if (!normalized.endsWith(".svelte")) return;
1671
+ if (!isThemeAwareComponent(normalized)) return;
1661
1672
  const comp = componentNameFromFile(normalized);
1662
1673
  generateDefaultConfig(comp, normalized);
1663
1674
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.14.1",
3
+ "version": "0.16.1",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 6/7.",
6
6
  "keywords": [
@@ -0,0 +1,55 @@
1
+ /**
2
+ * One-call boot for a live-tokens consumer app.
3
+ *
4
+ * Bundles the five idempotent `init*` hooks that previously had to be
5
+ * orchestrated by every consumer (see README "Bootstrap in main.ts"), runs
6
+ * `initializeTheme` in dev, optionally registers custom components, and
7
+ * mounts the consumer's App at the target.
8
+ *
9
+ * CSS imports stay with the consumer — token CSS is order-sensitive
10
+ * (defaults → editor overrides → fonts) and the consumer's
11
+ * `tokens.generated.css` is per-project. FA icons are side-effect-imported
12
+ * here because the dev overlay always needs them and a consumer must not
13
+ * have to remember to import an icon font.
14
+ */
15
+ import '@fortawesome/fontawesome-free/css/all.min.css';
16
+
17
+ import { mount, type Component } from 'svelte';
18
+ import * as cssVarSync from './core/cssVarSync';
19
+ import * as router from './core/routing/router';
20
+ import * as columnsOverlay from './overlay/columnsOverlay';
21
+ import * as editorStore from './core/store/editorStore';
22
+ import { initializeTheme } from './core/themes/themeInit';
23
+ import { registerComponent, type RegisterComponentEntry } from './component-editor/registry';
24
+
25
+ export interface BootLiveTokensOptions {
26
+ /** Consumer-authored components to register with the editor before mount. Dev-only. */
27
+ components?: RegisterComponentEntry[];
28
+ }
29
+
30
+ export async function bootLiveTokens(
31
+ App: Component<any, any, any>,
32
+ target: string | Element,
33
+ opts: BootLiveTokensOptions = {},
34
+ ): Promise<ReturnType<typeof mount>> {
35
+ cssVarSync.init();
36
+ router.init();
37
+ columnsOverlay.init();
38
+ editorStore.init();
39
+
40
+ if (import.meta.env.DEV) {
41
+ if (opts.components) {
42
+ for (const entry of opts.components) {
43
+ registerComponent(entry);
44
+ }
45
+ }
46
+ await initializeTheme();
47
+ }
48
+
49
+ const targetEl =
50
+ typeof target === 'string' ? document.querySelector(target) : target;
51
+ if (!targetEl) {
52
+ throw new Error(`bootLiveTokens: target ${JSON.stringify(target)} not found`);
53
+ }
54
+ return mount(App, { target: targetEl });
55
+ }
@@ -39,16 +39,22 @@
39
39
  ];
40
40
 
41
41
  // --- Title --------------------------------------------------------------
42
+ // Title is a flex card containing the label box + toggle box. Per-state
43
+ // tokens drive the card chrome (surface, border, padding); stateless gap
44
+ // and radius live in `titleLayoutTokens` since they don't vary across
45
+ // default/hover/active.
42
46
  function titleStateTokens(s: StatefulState): Token[] {
43
47
  return [
44
48
  { label: 'surface color', groupKey: 'title-surface', variable: `--sidenavigation-title-${s}-surface` },
45
- { label: 'divider color', groupKey: 'title-border', variable: `--sidenavigation-title-${s}-border` },
46
- { label: 'divider width', canBeLinked: true, groupKey: 'title-border-width', variable: `--sidenavigation-title-${s}-border-width` },
49
+ { label: 'border color', groupKey: 'title-border', variable: `--sidenavigation-title-${s}-border` },
50
+ { label: 'border width', canBeLinked: true, groupKey: 'title-border-width', variable: `--sidenavigation-title-${s}-border-width` },
47
51
  { label: 'padding', canBeLinked: true, groupKey: 'title-padding', variable: `--sidenavigation-title-${s}-padding` },
48
- { label: 'indicator color', groupKey: 'title-accent', variable: `--sidenavigation-title-${s}-accent` },
49
- { label: 'indicator width', canBeLinked: true, groupKey: 'title-accent-width', variable: `--sidenavigation-title-${s}-accent-width` },
50
52
  ];
51
53
  }
54
+ const titleLayoutTokens: Token[] = [
55
+ { label: 'gap', canBeLinked: true, groupKey: 'title-gap', variable: '--sidenavigation-title-gap' },
56
+ { label: 'corner radius', canBeLinked: true, groupKey: 'title-radius', variable: '--sidenavigation-title-radius' },
57
+ ];
52
58
  function titleStateTypeGroups(s: StatefulState): TypeGroupConfig[] {
53
59
  return [{
54
60
  legend: 'title label',
@@ -59,6 +65,14 @@
59
65
  lineHeightVariable: `--sidenavigation-title-${s}-label-line-height`,
60
66
  }];
61
67
  }
68
+ // Title label — structural inner box (stateless, like Panel). Sits inside
69
+ // the title bar so the header reads as: outer bar → [label box] [toggle box].
70
+ const titleLabelTokens: Token[] = [
71
+ { label: 'surface color', groupKey: 'title-label-surface', variable: '--sidenavigation-title-label-surface' },
72
+ { label: 'corner radius', canBeLinked: true, groupKey: 'title-label-radius', variable: '--sidenavigation-title-label-radius' },
73
+ { label: 'padding', canBeLinked: true, groupKey: 'title-label-padding', variable: '--sidenavigation-title-label-padding' },
74
+ ];
75
+
62
76
  const titleTypographyTokens: Token[] = STATEFUL_STATES.flatMap((s) => [
63
77
  { label: 'font family', canBeLinked: true, groupKey: 'title-label-font-family', variable: `--sidenavigation-title-${s}-label-font-family` },
64
78
  { label: 'font size', canBeLinked: true, groupKey: 'title-label-font-size', variable: `--sidenavigation-title-${s}-label-font-size` },
@@ -168,6 +182,8 @@
168
182
  const states: Record<string, Token[]> = {
169
183
  'Panel': panelTokens,
170
184
  ...Object.fromEntries(STATEFUL_STATES.map((s) => [`Title / ${STATE_LABELS[s]}`, titleStateTokens(s)])),
185
+ 'Title Layout': titleLayoutTokens,
186
+ 'Title Label': titleLabelTokens,
171
187
  ...Object.fromEntries(TOGGLE_STATES.map((s) => [`Toggle / ${STATE_LABELS[s]}`, toggleStateTokens(s)])),
172
188
  ...Object.fromEntries(STATEFUL_STATES.map((s) => [`Section / ${STATE_LABELS[s]}`, sectionStateTokens(s)])),
173
189
  ...Object.fromEntries(STATEFUL_STATES.map((s) => [`Item / ${STATE_LABELS[s]}`, itemStateTokens(s)])),
@@ -196,7 +212,6 @@
196
212
  ...STATEFUL_STATES.flatMap((s): Array<[string, string]> => [
197
213
  [`--sidenavigation-title-${s}-border-width`, `title ${s}`],
198
214
  [`--sidenavigation-title-${s}-padding`, `title ${s}`],
199
- [`--sidenavigation-title-${s}-accent-width`, `title ${s}`],
200
215
  [`--sidenavigation-title-${s}-label-font-family`, `title ${s}`],
201
216
  [`--sidenavigation-title-${s}-label-font-size`, `title ${s}`],
202
217
  [`--sidenavigation-title-${s}-label-font-weight`, `title ${s}`],
@@ -1,6 +1,10 @@
1
1
  export { default as LiveEditorOverlay } from './overlay/LiveEditorOverlay.svelte';
2
2
  export type { NavLink } from './core/routing/navLinkTypes';
3
3
  export { default as ColumnsOverlay } from './overlay/ColumnsOverlay.svelte';
4
+ export { default as LiveTokensRouter } from './overlay/LiveTokensRouter.svelte';
5
+ export type { RouteEntry, EditorRouteOverrides } from './overlay/LiveTokensRouter.svelte';
6
+ export { bootLiveTokens } from './bootstrap';
7
+ export type { BootLiveTokensOptions } from './bootstrap';
4
8
 
5
9
  export { columnsVisible, toggleColumns, init as initColumnsOverlay } from './overlay/columnsOverlay';
6
10
  export { configureEditor, storageKey } from './core/store/editorConfig';
@@ -0,0 +1,165 @@
1
+ <script module lang="ts">
2
+ import type { Component } from 'svelte';
3
+
4
+ /**
5
+ * One entry per consumer route. `label` + `icon` make it appear in the
6
+ * overlay's nav rail; `source` powers the "Page Source" button. Omit
7
+ * `label` to keep the route reachable by URL but absent from the rail
8
+ * (matches the existing playground/unlisted route pattern).
9
+ *
10
+ * Provide either `component` (eager) or `lazy` (code-split). Use `lazy`
11
+ * for any page that side-effect-imports a stylesheet at the top of its
12
+ * module, so those imports only evaluate when the route is visited and
13
+ * don't leak into unrelated routes (most importantly the editor pages).
14
+ */
15
+ export interface RouteEntry {
16
+ component?: Component<any, any, any>;
17
+ lazy?: () => Promise<{ default: Component<any, any, any> }>;
18
+ label?: string;
19
+ icon?: string;
20
+ source?: string;
21
+ /** Hide the overlay's "Page Source" button on this route. */
22
+ hidePageSource?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Override the default editor routes (`/editor`, `/components`). Pass a
27
+ * string to relocate the route; pass `false` to disable it entirely (no
28
+ * dispatch and, for `components`, no auto-injected nav-rail entry).
29
+ */
30
+ export interface EditorRouteOverrides {
31
+ editor?: string | false;
32
+ components?: string | false;
33
+ }
34
+ </script>
35
+
36
+ <script lang="ts">
37
+ import LiveEditorOverlay from './LiveEditorOverlay.svelte';
38
+ import ColumnsOverlay from './ColumnsOverlay.svelte';
39
+ import { route, navigate } from '../core/routing/router';
40
+
41
+ interface Props {
42
+ pages: Record<string, RouteEntry>;
43
+ editorRoutes?: EditorRouteOverrides;
44
+ }
45
+
46
+ let { pages, editorRoutes = {} }: Props = $props();
47
+
48
+ let editorEnabled = $derived(editorRoutes.editor !== false);
49
+ let componentsEnabled = $derived(editorRoutes.components !== false);
50
+ let editorPath = $derived(typeof editorRoutes.editor === 'string' ? editorRoutes.editor : '/editor');
51
+ let componentsPath = $derived(typeof editorRoutes.components === 'string' ? editorRoutes.components : '/components');
52
+
53
+ const isDev = import.meta.env.DEV;
54
+ let isEditor = $derived(isDev && editorEnabled && $route === editorPath);
55
+ let isComponentEditor = $derived(isDev && componentsEnabled && $route === componentsPath);
56
+
57
+ // Pages with a label show up in the nav rail, in declaration order. In dev,
58
+ // the components-editor route is auto-appended so it's reachable from every
59
+ // page (the convention every existing live-tokens consumer has settled on).
60
+ let navLinks = $derived([
61
+ ...Object.entries(pages)
62
+ .filter(([, e]) => !!e.label)
63
+ .map(([path, e]) => ({ path, label: e.label!, icon: e.icon ?? '' })),
64
+ ...(isDev && componentsEnabled
65
+ ? [{ path: componentsPath, label: 'Components', icon: 'fa-puzzle-piece' }]
66
+ : []),
67
+ ]);
68
+
69
+ let pageSources = $derived(
70
+ Object.fromEntries(
71
+ Object.entries(pages)
72
+ .filter(([, e]) => !!e.source)
73
+ .map(([path, e]) => [path, e.source!]),
74
+ ),
75
+ );
76
+
77
+ // Components-editor route always hides the page-source button (matches the
78
+ // convention from the original consumer pattern).
79
+ let hidePageSourceOn = $derived([
80
+ ...Object.entries(pages)
81
+ .filter(([, e]) => e.hidePageSource)
82
+ .map(([path]) => path),
83
+ ...(componentsEnabled ? [componentsPath] : []),
84
+ ]);
85
+
86
+ // Dispatch the current route. Editor pages are dynamically imported so they
87
+ // don't ship in non-editor route bundles. Consumer pages dispatch via
88
+ // `entry.lazy()` when provided (so each page's CSS side-effect imports stay
89
+ // out of other routes), or via the eagerly-imported `entry.component`.
90
+ let pagePromise = $derived.by(() => {
91
+ if (isEditor) return import('../pages/Editor.svelte');
92
+ if (isComponentEditor) return import('../pages/ComponentEditorPage.svelte');
93
+ const entry = pages[$route] ?? pages['/'];
94
+ if (!entry) return Promise.resolve({ default: null as unknown as Component<any, any, any> });
95
+ if (entry.lazy) return entry.lazy();
96
+ if (entry.component) return Promise.resolve({ default: entry.component });
97
+ return Promise.resolve({ default: null as unknown as Component<any, any, any> });
98
+ });
99
+
100
+ // In-app link interception: turn left-clicks on internal `/...` anchors into
101
+ // navigate() calls so router state updates without a full reload. Modifier
102
+ // keys (cmd/ctrl/shift/alt) pass through to the browser's default handling.
103
+ function handleClick(e: MouseEvent) {
104
+ const anchor = (e.target as HTMLElement).closest('a[href]');
105
+ if (!anchor) return;
106
+ const href = anchor.getAttribute('href');
107
+ if (!href || !href.startsWith('/')) return;
108
+ if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
109
+ e.preventDefault();
110
+ navigate(href);
111
+ }
112
+ </script>
113
+
114
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions, a11y_no_static_element_interactions -->
115
+ <div
116
+ class="lt-app"
117
+ class:is-editor={isEditor}
118
+ class:is-component-editor={isComponentEditor}
119
+ onclick={handleClick}
120
+ >
121
+ <LiveEditorOverlay {navLinks} {pageSources} {hidePageSourceOn} {editorPath} />
122
+ <ColumnsOverlay />
123
+
124
+ {#await pagePromise then m}
125
+ {@const PageComponent = m.default}
126
+ {#if PageComponent}
127
+ <PageComponent />
128
+ {/if}
129
+ {/await}
130
+ </div>
131
+
132
+ <style>
133
+ :global(html) {
134
+ scrollbar-gutter: stable;
135
+ }
136
+
137
+ :global(body) {
138
+ background: var(--page-bg);
139
+ background-attachment: var(--page-bg-attachment, fixed);
140
+ }
141
+
142
+ .lt-app {
143
+ width: 100%;
144
+ min-height: 100vh;
145
+ color: var(--text-primary);
146
+ padding-bottom: 12rem;
147
+ /* Set by LiveEditorOverlay when docked-open. Extends layout past the
148
+ fixed panel so the viewport can scroll to reveal hidden content. */
149
+ padding-right: var(--lt-overlay-scroll-pad, 0px);
150
+ }
151
+
152
+ .lt-app.is-editor {
153
+ padding-bottom: 0;
154
+ background: black;
155
+ }
156
+
157
+ .lt-app.is-component-editor {
158
+ min-height: 0;
159
+ height: 100vh;
160
+ padding-bottom: 0;
161
+ padding-right: 0;
162
+ background: black;
163
+ overflow: hidden;
164
+ }
165
+ </style>
@@ -139,28 +139,28 @@
139
139
  class:force-footer-hover={forceHoverPart === 'footer'}
140
140
  class:force-footer-active={forceActivePart === 'footer'}
141
141
  >
142
- <!-- Toggle is a single persistent element. Its `left` position is
143
- calc'd from the panel-width tokens so it transitions smoothly
144
- between right-of-title (open) and centre-of-rail (closed) using
145
- the same duration/easing tokens as the rail width. -->
146
- <button
147
- type="button"
148
- class="sn-toggle"
149
- onclick={fireToggle}
150
- aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}
151
- aria-expanded={open}
152
- >
153
- <i class="fa-solid fa-angles-right" aria-hidden="true"></i>
154
- </button>
155
-
156
- {#if open}
157
- <!-- Open layout. Title bar reserves space for the persistent toggle on
158
- the right; the menu is locked at the open-width so it can't reflow
159
- while the rail expands around it. -->
160
- <header class="sn-title" class:active={titleActive}>
142
+ <!-- Header is always rendered so the toggle (a child here) survives the
143
+ collapsed state. The label is the only conditional child when the
144
+ rail is closed, the header reduces to just the toggle, centred via the
145
+ toggle's own `left` calc. Header stays locked to open-width regardless;
146
+ the aside's overflow clips it during the close animation. -->
147
+ <header class="sn-title" class:active={titleActive}>
148
+ {#if open}
161
149
  <a href={titleHref} class="sn-title-label">{titleLabel}</a>
162
- </header>
150
+ {/if}
151
+
152
+ <button
153
+ type="button"
154
+ class="sn-toggle"
155
+ onclick={fireToggle}
156
+ aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}
157
+ aria-expanded={open}
158
+ >
159
+ <i class="fa-solid fa-angles-right" aria-hidden="true"></i>
160
+ </button>
161
+ </header>
163
162
 
163
+ {#if open}
164
164
  <div class="sn-menu">
165
165
  {#each sections as section (section.path)}
166
166
  <div class="sn-section">
@@ -228,6 +228,22 @@
228
228
  --sidenavigation-close-duration: var(--duration-150);
229
229
  --sidenavigation-close-easing: var(--ease-out-quart);
230
230
 
231
+ /* Title — layout (stateless). The header is a flex container that hosts
232
+ the label box and the toggle box side-by-side; gap and radius drive
233
+ the card-like outer shell. */
234
+ --sidenavigation-title-gap: var(--space-8);
235
+ --sidenavigation-title-radius: var(--radius-md);
236
+
237
+ /* Title label — structural inner box (stateless). Renders as a row inside
238
+ the title bar so the header reads as: outer card → [label box] [toggle
239
+ box]. align-items: stretch on the header equalises its height with the
240
+ toggle's. */
241
+ --sidenavigation-title-label-surface: var(--color-transparent);
242
+ --sidenavigation-title-label-border: var(--border-canvas-faint);
243
+ --sidenavigation-title-label-border-width: var(--border-width-1);
244
+ --sidenavigation-title-label-radius: var(--radius-md);
245
+ --sidenavigation-title-label-padding: var(--space-6);
246
+
231
247
  /* Title — default */
232
248
  --sidenavigation-title-default-surface: var(--color-transparent);
233
249
  --sidenavigation-title-default-border: var(--border-canvas-faint);
@@ -242,7 +258,7 @@
242
258
  --sidenavigation-title-default-label-line-height: var(--line-height-sm);
243
259
 
244
260
  /* Title — hover */
245
- --sidenavigation-title-hover-surface: var(--surface-canvas);
261
+ --sidenavigation-title-hover-surface: var(--color-transparent);
246
262
  --sidenavigation-title-hover-border: var(--border-canvas-faint);
247
263
  --sidenavigation-title-hover-border-width: var(--border-width-1);
248
264
  --sidenavigation-title-hover-padding: var(--space-12);
@@ -428,34 +444,27 @@
428
444
  --_border: var(--sidenavigation-title-default-border);
429
445
  --_border-width: var(--sidenavigation-title-default-border-width);
430
446
  --_padding: var(--sidenavigation-title-default-padding);
431
- --_indicator: var(--sidenavigation-title-default-accent);
432
- --_indicator-width: var(--sidenavigation-title-default-accent-width);
433
447
  --_label: var(--sidenavigation-title-default-label);
434
448
  --_label-family: var(--sidenavigation-title-default-label-font-family);
435
449
  --_label-size: var(--sidenavigation-title-default-label-font-size);
436
450
  --_label-weight: var(--sidenavigation-title-default-label-font-weight);
437
451
  --_label-line-height: var(--sidenavigation-title-default-label-line-height);
438
452
 
439
- /* Positioning context for the absolutely-anchored toggle button. */
440
- position: relative;
453
+ /* The header is a pure flex row: card-like outer with the label box and
454
+ toggle box as siblings. No absolute positioning — the label fills with
455
+ flex:1, the toggle is a fixed-size sibling. justify-content: center
456
+ takes over when the label is removed in the collapsed state and the
457
+ toggle becomes the only child. */
441
458
  flex: 0 0 auto;
442
- /* Lock to the open-width so the title doesn't reflow during the rail
443
- expansion (3rem → 16rem on open). border-box keeps the outer width
444
- equal to the token value (otherwise padding + border would push the
445
- toggle past the rail's right edge). */
446
459
  box-sizing: border-box;
447
- width: var(--sidenavigation-panel-open-width);
448
460
  display: flex;
449
- align-items: center;
450
- gap: var(--space-12);
461
+ align-items: stretch;
462
+ justify-content: center;
463
+ gap: var(--sidenavigation-title-gap);
451
464
  background: var(--_surface);
452
- border-bottom: var(--_border-width) solid var(--_border);
453
- border-left: var(--_indicator-width) solid var(--_indicator);
465
+ border-radius: var(--sidenavigation-title-radius);
454
466
  @include themed-padding(--_padding);
455
- /* Right padding clears the absolutely-positioned toggle inside this
456
- header (toggle width ~36px + 8px breathing room). */
457
- padding-right: calc(var(--space-12) + 44px);
458
- transition: background var(--duration-150), border-color var(--duration-150);
467
+ transition: background var(--duration-150);
459
468
  }
460
469
 
461
470
  .sn-title:hover:not(.active),
@@ -464,8 +473,6 @@
464
473
  --_border: var(--sidenavigation-title-hover-border);
465
474
  --_border-width: var(--sidenavigation-title-hover-border-width);
466
475
  --_padding: var(--sidenavigation-title-hover-padding);
467
- --_indicator: var(--sidenavigation-title-hover-accent);
468
- --_indicator-width: var(--sidenavigation-title-hover-accent-width);
469
476
  --_label: var(--sidenavigation-title-hover-label);
470
477
  --_label-family: var(--sidenavigation-title-hover-label-font-family);
471
478
  --_label-size: var(--sidenavigation-title-hover-label-font-size);
@@ -478,8 +485,6 @@
478
485
  --_border: var(--sidenavigation-title-active-border);
479
486
  --_border-width: var(--sidenavigation-title-active-border-width);
480
487
  --_padding: var(--sidenavigation-title-active-padding);
481
- --_indicator: var(--sidenavigation-title-active-accent);
482
- --_indicator-width: var(--sidenavigation-title-active-accent-width);
483
488
  --_label: var(--sidenavigation-title-active-label);
484
489
  --_label-family: var(--sidenavigation-title-active-label-font-family);
485
490
  --_label-size: var(--sidenavigation-title-active-label-font-size);
@@ -487,7 +492,18 @@
487
492
  --_label-line-height: var(--sidenavigation-title-active-label-line-height);
488
493
  }
489
494
 
495
+ /* In the collapsed state the header narrows with the rail. Drop horizontal
496
+ padding so the toggle (single remaining flex child) still fits in the
497
+ closed-width aside — open-state padding alone would push it out. */
498
+ .sidenavigation.collapsed .sn-title {
499
+ padding-left: 0;
500
+ padding-right: 0;
501
+ }
502
+
490
503
  .sn-title-label {
504
+ background: var(--sidenavigation-title-label-surface);
505
+ border-radius: var(--sidenavigation-title-label-radius);
506
+ @include themed-padding(--sidenavigation-title-label-padding);
491
507
  color: var(--_label);
492
508
  font-family: var(--_label-family);
493
509
  font-size: var(--_label-size);
@@ -495,6 +511,8 @@
495
511
  line-height: var(--_label-line-height);
496
512
  text-decoration: none;
497
513
  flex: 1 1 auto;
514
+ display: flex;
515
+ align-items: center;
498
516
  min-width: 0;
499
517
  white-space: nowrap;
500
518
  overflow: hidden;
@@ -510,62 +528,38 @@
510
528
  --_icon: var(--sidenavigation-toggle-default-icon);
511
529
  --_icon-size: var(--sidenavigation-toggle-default-icon-size);
512
530
 
513
- /* Persistent element anchored to the panel (not the title). Open-state
514
- `left` puts it near the right edge of the open-width title; collapsed
515
- `left` centres it in the closed-width rail. Both calc'd from the
516
- same width tokens so they stay in sync if the consumer overrides. */
517
- position: absolute;
518
- /* Toggle's outer width is derived from its constituent tokens — icon
519
- + padding (both sides) + border (both sides). Stays accurate when a
520
- consumer customizes any of those without re-hardcoding here. */
521
- --_toggle-width: calc(
522
- var(--sidenavigation-toggle-default-icon-size)
523
- + 2 * var(--sidenavigation-toggle-default-padding)
524
- + 2 * var(--sidenavigation-toggle-default-border-width)
525
- );
526
- top: var(--space-12);
527
- left: calc(var(--sidenavigation-panel-open-width) - var(--_toggle-width) - var(--space-8));
528
- z-index: 1;
529
- transition:
530
- left var(--sidenavigation-open-duration) var(--sidenavigation-open-easing),
531
- background var(--duration-150),
532
- border-color var(--duration-150),
533
- color var(--duration-150);
534
-
531
+ /* Regular flex child of .sn-title no absolute positioning. The header
532
+ row's justify-content+flex:1-on-label combo puts the toggle at the
533
+ right edge when the label is present and centres it when the label
534
+ is removed in the collapsed state. */
535
535
  display: inline-flex;
536
536
  align-items: center;
537
537
  justify-content: center;
538
- flex-shrink: 0;
538
+ flex: 0 0 auto;
539
+ box-sizing: border-box;
539
540
  background: var(--_surface);
540
541
  border: var(--_border-width) solid var(--_border);
541
542
  border-radius: var(--_radius);
542
543
  color: var(--_icon);
543
544
  @include themed-padding(--_padding);
544
545
  cursor: pointer;
546
+ transition:
547
+ background var(--duration-150),
548
+ border-color var(--duration-150),
549
+ color var(--duration-150);
545
550
  }
546
551
 
547
552
  .sn-toggle i {
548
553
  font-size: var(--_icon-size);
549
554
  line-height: 1;
550
555
  /* Icon points right by default (expand). Open state flips it to point
551
- left (collapse). Rotation tweens with the position so the affordance
552
- morphs smoothly between the two states. */
556
+ left (collapse). Rotation tweens with the rail's width transition so
557
+ the affordance morphs smoothly between the two states. */
553
558
  transition: transform var(--sidenavigation-open-duration) var(--sidenavigation-open-easing);
554
559
  }
555
560
  .sidenavigation:not(.collapsed) .sn-toggle i {
556
561
  transform: rotate(180deg);
557
562
  }
558
-
559
- /* Collapsed: centre the toggle in the rail, and swap to close-* timing
560
- tokens so the leftward slide matches the rail's shrink. */
561
- .sidenavigation.collapsed .sn-toggle {
562
- left: calc((var(--sidenavigation-panel-closed-width) - var(--_toggle-width)) / 2);
563
- transition:
564
- left var(--sidenavigation-close-duration) var(--sidenavigation-close-easing),
565
- background var(--duration-150),
566
- border-color var(--duration-150),
567
- color var(--duration-150);
568
- }
569
563
  .sidenavigation.collapsed .sn-toggle i {
570
564
  transition: transform var(--sidenavigation-close-duration) var(--sidenavigation-close-easing);
571
565
  }