@motion-proto/live-tokens 0.49.0 → 0.50.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 (63) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist-plugin/generateColorsAndType/index.cjs +13 -2
  3. package/dist-plugin/generateColorsAndType/index.js +13 -2
  4. package/dist-plugin/index.cjs +2 -0
  5. package/dist-plugin/index.js +2 -0
  6. package/package.json +6 -1
  7. package/src/editor/bootstrap.ts +2 -0
  8. package/src/editor/component-editor/ButtonEditor.svelte +1 -1
  9. package/src/editor/component-editor/CardEditor.svelte +5 -2
  10. package/src/editor/component-editor/IconButtonEditor.svelte +1 -1
  11. package/src/editor/component-editor/ImageEditor.svelte +1 -1
  12. package/src/editor/component-editor/SectionDividerEditor.svelte +4 -1
  13. package/src/editor/component-editor/SideNavigationEditor.svelte +6 -0
  14. package/src/editor/component-editor/TabBarEditor.svelte +1 -5
  15. package/src/editor/component-editor/scaffolding/ComponentEditorBase.svelte +8 -1
  16. package/src/editor/component-editor/scaffolding/ComponentFileManager.svelte +10 -32
  17. package/src/editor/component-editor/scaffolding/StateBlock.svelte +2 -0
  18. package/src/editor/component-editor/scaffolding/TypeEditor.svelte +3 -1
  19. package/src/editor/component-editor/scaffolding/types.ts +3 -0
  20. package/src/editor/core/components/componentConfigService.ts +25 -0
  21. package/src/editor/core/cssVarSync.ts +65 -7
  22. package/src/editor/core/fonts/fontLoader.ts +12 -3
  23. package/src/editor/core/fonts/fontWeightAvailability.ts +97 -0
  24. package/src/editor/core/preview/lookPreview.ts +23 -18
  25. package/src/editor/core/store/editorRenderer.ts +22 -7
  26. package/src/editor/core/store/editorStore.ts +31 -0
  27. package/src/editor/core/store/gradientSource.ts +5 -0
  28. package/src/editor/core/themes/colorsAndTypeService.ts +2 -12
  29. package/src/editor/core/themes/migrations/2026-05-20-sectiondivider-slim-variants.ts +4 -1
  30. package/src/editor/core/themes/migrations/2026-05-21-sectiondivider-spacing-to-padding.ts +4 -1
  31. package/src/editor/core/themes/migrations/2026-05-25-cornerbadge-flatten-variants.ts +8 -0
  32. package/src/editor/core/themes/slices/fonts.ts +4 -5
  33. package/src/editor/core/themes/themeDocumentSync.ts +66 -0
  34. package/src/editor/core/themes/themeInit.ts +7 -10
  35. package/src/editor/core/themes/themeService.ts +10 -3
  36. package/src/editor/docs/content/themes-workflow.md +14 -11
  37. package/src/editor/docs/content/where-themes-live.md +7 -2
  38. package/src/editor/docs/content.generated.ts +2 -2
  39. package/src/editor/ui/FileLoadList.svelte +1 -0
  40. package/src/editor/ui/FontStackEditor.svelte +1 -2
  41. package/src/editor/ui/GradientEditor.svelte +5 -1
  42. package/src/editor/ui/ProjectFontsSection.svelte +0 -5
  43. package/src/editor/ui/ThemePanel.svelte +206 -42
  44. package/src/editor/ui/UIDialog.svelte +7 -1
  45. package/src/editor/ui/UIFontFamilySelector.svelte +13 -13
  46. package/src/editor/ui/UIFontSizeSelector.svelte +5 -5
  47. package/src/editor/ui/UIFontWeightSelector.svelte +52 -2
  48. package/src/editor/ui/UIOptionItem.svelte +14 -1
  49. package/src/editor/ui/UIPaddingSelector.svelte +4 -3
  50. package/src/editor/ui/UIPaletteSelector.svelte +20 -13
  51. package/src/editor/ui/UIRelinkConfirmDialog.svelte +18 -3
  52. package/src/editor/ui/UITextTransformSelector.svelte +5 -5
  53. package/src/editor/ui/UITokenSelector.svelte +29 -7
  54. package/src/live-tokens/data/themes/spring-meadow.json +14 -12
  55. package/src/system/components/Button.svelte +2 -1
  56. package/src/system/components/FloatingTokenTags.css +2 -1
  57. package/src/system/components/FloatingTokenTags.svelte +11 -0
  58. package/src/system/components/IconButton.svelte +2 -1
  59. package/src/system/components/Image.svelte +9 -3
  60. package/src/system/components/ImageLightbox.svelte +2 -0
  61. package/src/system/components/SectionDivider.svelte +20 -2
  62. package/src/system/components/SideNavigation.svelte +15 -1
  63. package/src/system/styles/fonts.css +6 -6
@@ -34,8 +34,8 @@ deleting anything else never breaks it.
34
34
 
35
35
  - **Editing** changes the page through CSS variables. The editor keeps your
36
36
  edits in the browser as you work and writes them to the `_working.json`
37
- buffers when you save a component. A buffer exists only where the live layer
38
- differs from the active theme's saved layer, so a fresh project has none.
37
+ buffers when you save a component. When the Theme panel finds several dirty
38
+ components, **Save all** writes those buffers together.
39
39
  - **Save** captures the buffers into the open theme's file. That file is the
40
40
  durable copy of your look; matching buffers are then removed.
41
41
  - **Load** clears the buffers and points `themes/_active.json` at the theme you
@@ -49,6 +49,11 @@ The `default.json` files are the shipped baseline. The editor derives them at
49
49
  boot and refreshes them when the package updates; it never saves your work
50
50
  over them.
51
51
 
52
+ Projects upgraded from 0.48 may initially contain working files copied from the
53
+ active theme. On the first dev-server boot, exact copies are removed
54
+ automatically. Any file that differs is kept as unsaved work, so no migration
55
+ command is required.
56
+
52
57
  ## What to commit
53
58
 
54
59
  All of it. The data tree is designed to live in git: themes diff readably, the
@@ -6,6 +6,6 @@ export const docContent: Record<string, string> = {
6
6
  "creating-components": "# Creating components\n\nThe package ships about 25 editable components. When you need one it doesn't\nhave, you can make your own Svelte component editable, so anyone using the\neditor can re-point its colours, type, and spacing without touching code.\n\nThe simplest way is to ask Claude. The package bundles a Claude Code skill that\nknows the conventions, writes the files, and checks the result for you.\n\n## Install the skills\n\n```bash\nnpx @motion-proto/live-tokens setup-claude\n```\n\nThis copies the bundled skills into your project's `.claude/skills/`. Once\nthey're there, Claude Code picks them up automatically.\n\n## Ask for a component\n\nDescribe what you want in plain English. Phrases like these trigger the skill:\n\n- \"Add a Toggle component to live-tokens\"\n- \"Make this Svelte component editable in the live-tokens editor\"\n- \"Create a Stat component with a value and a label\"\n\nClaude asks any clarifying questions it needs (which variants, which states,\nwhich parts), then writes the component, registers it with the editor, and runs\nits verification checklist. When it finishes, open `/live-tokens/components` to see your new\ncomponent in the editor and confirm everything works.\n\n## What you get\n\n- A runtime component whose editable properties default to your theme tokens.\n- An editor entry that appears under **Custom** in the `/live-tokens/components` view.\n- The naming and wiring handled for you, so the component fits the system.\n\nAdvanced authors who want to write a component by hand can read the naming and\nstate-model conventions shipped in the package\n(`src/system/styles/CONVENTIONS.md` and the skill's own `SKILL.md`).\n",
7
7
  "editing-tokens": "# Editing tokens\n\nA tour of the editor. The page behind it repaints on every change; saving\nwrites a theme file you can reload later.\n\nThe editor has two views:\n\n- **Tokens**: the design-system primitives (colour, type, spacing, and so on).\n They apply everywhere your site uses them.\n- **Components**: per-component editors. Re-Assign what tokens a component uses\n without changing the underlying system.\n\nThis page covers **Tokens**. For components, see\n[Creating components](creating-components.md).\n\n## Palettes\n\nMost colour work happens here. Each palette (Brand, Accent, Neutral, Canvas,\nSuccess, Warning, Info, Danger, and a few more) has:\n\n- **Base colour.** Pick a hex; the palette derives an 11-step ramp (100 to 950)\n from it.\n- **Curves.** Two curves shape how lightness and saturation fall off across the\n ramp. Drag the handles to bias it darker, lighter, or more saturated.\n- **Overrides.** Lock a single step to a hand-picked hex when the curve doesn't\n land where you want.\n\nEditing a palette base ripples through every colour that depends on it, in real\ntime. Colours use OKLCH, so the ramp stays perceptually even across hues\nwithout muddy mid-tones.\n\n## Type\n\n- **Fonts.** Add sources from Google Fonts, Adobe (Typekit), a CSS URL, or an\n inline `@font-face`. The font loads in the page as soon as you add it.\n- **Stacks.** Named font cascades you reference by token, such as a display\n stack and a body stack.\n- **Sizes and weights.** A t-shirt scale (xs, sm, md, lg, xl, 2xl…) for size and\n a numeric scale (100 to 900) for weight.\n\n## Spacing, radius, shadow\n\nNumeric scales with a slider per step.\n\n- **Spacing**: the padding, gap, and margin scale.\n- **Radius**: none through full.\n- **Shadow**: colour, offset, blur, spread, and opacity per step, with stacked\n shadows supported.\n\nChange a step and every element using it repaints.\n\n## Overlays and gradients\n\n- **Overlays** are translucent tints layered over surfaces, like the subtle\n tint a card gets on hover. Set a colour and opacity per state.\n- **Gradients** are reusable gradient tokens with a stop list and direction, for\n hero panels and accent backgrounds.\n\n## Columns\n\nThe page-grid overlay. Set column count, gutter, and outer margin, and toggle\nthe visual guide with `Cmd/Ctrl+G`. Pages built on the column system reflow\nlive.\n\n## Saving\n\nThe editor saves to your browser continuously, so work survives a reload\nmid-edit. Writing a file is a separate step: the **Theme** panel at the foot of\nthe sidebar has **Save**, **Save As**, and **Load**, and each theme is one JSON\nfile under `src/live-tokens/data/themes/`.\n\nThe header gives you undo/redo (`Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`). You can keep\nmany themes side by side; one is open at a time, and only **Adopt** publishes\none. See [Themes](themes-workflow.md) for the full lifecycle.\n",
8
8
  "getting-started": "# Getting started\n\nScaffold a live token site in a moments. You need Node 20 or later, a\npackage manager (npm, pnpm, or yarn), and a browser. Open claude code in your repo and start building.\n\n## Scaffold a new app\n\n```bash\nnpm create @motion-proto/live-tokens@latest my-app\ncd my-app\nnpm install\nnpm run dev\n```\n\nOpen the URL Vite prints (usually `http://localhost:5173`). You get a\none-page Svelte + Vite app that depends on the published package, with the\neditor wired up and the full component set ready to import.\n\n`npx @motion-proto/live-tokens create my-app` runs the same scaffold without\nthe initialiser package.\n\n### What the scaffold gives you\n\nEvery editable file lives under `src/` and is committed, so `npm install` and\nversion upgrades never touch your styles. The package code stays in\n`node_modules`.\n\n| Path | What it is |\n|------|------------|\n| `src/pages/Home.svelte` | The starter page. Replace it with your own content. |\n| `src/App.svelte` | Your routes. `<LiveTokensRouter>` adds dev-only routes under a reserved `/live-tokens/*` namespace: `/live-tokens/editor`, `/live-tokens/components`, and `/live-tokens/docs`. |\n| `src/system/styles/tokens.css` | Your base token vocabulary, hand-authored. |\n| `src/styles/site.css` | Themed page typography, yours to edit. |\n\n## Your first edit\n\n1. Run `npm run dev` and open the home page.\n2. Click **Open Token Editor**, or visit `/live-tokens/editor`. The editor opens beside\n the page.\n3. Open **Palettes**, pick **Brand**, and change the base hex. The page\n repaints as you type.\n4. In the **Theme** panel at the foot of the sidebar, choose **Save As**. Your\n theme appears as JSON under `src/live-tokens/data/themes/`.\n5. Reload. The editor reopens on your theme, so the page returns as you left\n it.\n\n## What you just changed\n\nEvery edit sets a CSS custom property on `:root`. Your components read those\nproperties through `var(--...)`. There is no token build step and no\npreprocessor rewriting your code: the page renders against plain CSS variables\nthe editor swaps live.\n\nTo ship, click **Adopt** in the Theme panel. That saves the open theme and bakes\nit into `src/live-tokens/data/tokens.generated.css`, which your build bundles\nalongside `tokens.css`. Adopt is the only action that changes what your site\nships, so try any look you like first. The editor itself never reaches\nproduction.\n\nAlready have a Svelte 5 + Vite app? The\n[README](https://github.com/motionproto/live-tokens#readme) covers installing\ninto an existing project.\n\n## Where to go next\n\n- **[Editing tokens](editing-tokens.md)**: a tour of the editor.\n- **[Themes](themes-workflow.md)**: save, switch, and ship.\n- **[Creating components](creating-components.md)**: make your own component\n editable.\n",
9
- "themes-workflow": "# Themes\n\nSave your work, switch between looks, and ship one to production.\n\n## The Theme panel\n\nThe **Theme** panel at the foot of the editor sidebar holds the whole look:\ncolors, type, and a setting for every component you changed, in one file. It\ncarries the name the look ships under, whether production is running it, and\n**Adopt**. Two parts sit under it, each a read-out rather than a file to manage.\n\n- **Colors & Type** holds the design tokens. Components read those tokens to\n define their appearance. It names the two faces the page is showing.\n- **Components** counts how many components run something the theme does not\n carry, and opens the component editors.\n\nA theme holds its own copy of every part, so one theme can never break another.\n\n## How themes work\n\nA theme is a document, and the editor works the way any editor does.\n\n- **A theme** is a named JSON file in `src/live-tokens/data/themes/`. It carries\n the whole look: the colors and type plus a setting for every component you\n changed.\n- **The open theme** is the one the editor is working on, named in\n `themes/_active.json`. One at a time.\n- **Your unsaved edits** are what the page shows right now. The editor keeps\n them in your browser as you work and writes them to a buffer, `_working.json`,\n one slot per part of the look. **Save** captures that buffer into the open\n theme.\n- **The production theme** is the one your site ships, named in\n `themes/_production.json`. Only **Adopt** changes it.\n\nAbsence is the answer for anything untouched: a buffer exists only where the\nlive look diverges from the active theme, so a newly opened theme has none.\n\n## Saving\n\nIn the Theme panel:\n\n- **Save** captures the look on screen into the open theme. Your colors and type\n go in as part of it, so there is nothing to save first.\n- **Save As** names a new theme. Use it for your first save and for forking.\n\nComponent edits are the exception. Each component editor holds its own unsaved\nstate, which this panel cannot write, so save a component in its editor before\ncapturing it. The panel says how many are waiting.\n\nNames are tidied to lowercase with hyphens, so \"My Brand!\" becomes `my-brand`,\nand a leading underscore is dropped: those names are reserved for the buffer.\n**Motion Proto** is the built-in theme and is read-only. You can always return\nto it, and the editor never overwrites it, so start your own with **Save As**.\n\n## Switching\n\n**Load** lists your saved themes and the seven example looks. Picking one shows\nit on the page as a preview with nothing written to disk, so you can try each\nlook and compare. **Save** in that window opens the previewed theme: the active\npointer changes, the buffers clear, components it does not carry fall through\nto their defaults, and the editor works on it from then on. **Cancel** returns\nyou to where you were.\nTrying a look never changes what your site ships.\n\n**Colors and type only. Keep my shapes.** narrows the load to the palette and\nthe fonts: your component settings stay as they are and the theme you have open\nstays open. Saved colors and type files are listed there too, marked *colors &\ntype*, and picking one is always that narrower load.\n\n## Shipping\n\n**Adopt**, in the Theme panel, is the \"ship it\" step, and it ships the whole\nlook. It saves the open theme, then bakes that theme into\n`src/live-tokens/data/tokens.generated.css`, which your build bundles alongside\n`tokens.css`: the colors and type plus every component the theme carries. Fonts\nregenerate to match. The line under the theme name says whether production is\nrunning this theme.\n\nProduction is one saved theme, so nothing else publishes. Trying a look, moving\na token, saving a theme: all of it leaves the generated CSS alone until you\nAdopt. A component editor's Adopt runs the same whole-look step, because a\ncomponent never ships alone. Adopting while Motion Proto is open saves your look\nas a theme of your own first, since the built-in one is read-only.\n\nProduction builds (`npm run build`) ship only that plain CSS and your\ncomponents. No editor, no JSON loading, no runtime indirection.\n\n## Keeping your work safe\n\nEverything under `src/live-tokens/data/` is plain JSON, so commit it. Themes show\nup as readable diffs you can review per branch, and the buffer shows up as the\nwork you have not saved into a theme yet. Nothing is backed up anywhere else:\ngit is your safety net. To experiment freely, **Save As** a new name first, then\nedit.\n\n## Where to go next\n\n- **[Where themes live](where-themes-live.md)**: the files behind all of this,\n and what writes each one.\n- **[Creating components](creating-components.md)**: make your own components\n editable in the same editor.\n",
10
- "where-themes-live": "# Where themes live\n\nEverything the editor writes is plain JSON and CSS inside your project. There\nis no database and no hidden state: the files are the storage, and git is the\nhistory.\n\n## The data tree\n\n```\nsrc/live-tokens/data/\n themes/\n _active.json names the theme the editor has open\n _production.json names the theme your site ships\n default.json Motion Proto, the built-in look, rewritten at boot\n my-brand.json a saved theme: the whole look in one file\n colors-and-type/\n _working.json unsaved colors and type edits\n component-configs/\n button/\n default.json Button's shipped settings, derived at boot\n _working.json unsaved Button edits\n my-button.json a preset you saved from the Button editor\n tokens.generated.css the baked CSS your production build ships\nsrc/system/styles/\n tokens.css your token vocabulary, hand-authored, never written\n fonts.css font imports, rewritten when you Adopt\n```\n\nA saved theme carries the whole look by value: the colors and type plus a\nsetting for every component you changed. It depends on no other file, so\ndeleting anything else never breaks it.\n\n## What writes when\n\n- **Editing** changes the page through CSS variables. The editor keeps your\n edits in the browser as you work and writes them to the `_working.json`\n buffers when you save a component. A buffer exists only where the live layer\n differs from the active theme's saved layer, so a fresh project has none.\n- **Save** captures the buffers into the open theme's file. That file is the\n durable copy of your look; matching buffers are then removed.\n- **Load** clears the buffers and points `themes/_active.json` at the theme you\n picked. Live reads fall through to that file. Nothing else changes, so trying\n looks is free and ordinary switching changes only the pointer.\n- **Adopt** points `themes/_production.json` at the open theme, bakes it into\n `tokens.generated.css`, and rewrites `fonts.css` to match. It is the only\n action that changes what your site ships.\n\nThe `default.json` files are the shipped baseline. The editor derives them at\nboot and refreshes them when the package updates; it never saves your work\nover them.\n\n## What to commit\n\nAll of it. The data tree is designed to live in git: themes diff readably, the\ntwo pointers say what is open and what ships, and a `_working.json` in a diff\nis exactly the work you have not yet saved into a theme. Nothing is backed up\nanywhere else.\n\n## Where to go next\n\n- **[Themes](themes-workflow.md)**: the workflow built on these files: saving,\n loading, and shipping.\n",
9
+ "themes-workflow": "# Themes\n\nSave your work, switch between looks, and ship one to production.\n\n## The Theme panel\n\nThe **Theme** panel at the foot of the editor sidebar holds the whole look:\ncolors, type, and a setting for every component you changed, in one file. It\ncarries the name the look ships under, whether production is running it, and\n**Adopt**. Two parts sit under it, each a read-out rather than a file to manage.\n\n- **Colors & Type** holds the design tokens. Components read those tokens to\n define their appearance. It names the two faces the page is showing.\n- **Components** counts how many components run something the theme does not\n carry, and opens the component editors.\n\nA theme holds its own copy of every part, so one theme can never break another.\n\n## How themes work\n\nA theme is a document, and the editor works the way any editor does.\n\n- **A theme** is a named JSON file in `src/live-tokens/data/themes/`. It carries\n the whole look: the colors and type plus a setting for every component you\n changed.\n- **The open theme** is the one the editor is working on, named in\n `themes/_active.json`. One at a time.\n- **Your unsaved edits** are what the page shows right now. The editor keeps\n them in your browser as you work and writes them to a buffer, `_working.json`,\n one slot per part of the look. **Save** captures that buffer into the open\n theme.\n- **The production theme** is the one your site ships, named in\n `themes/_production.json`. **Adopt** changes it; saving a preset in the Theme\n Picker performs that Adopt for you.\n\nAbsence is the answer for anything untouched: a buffer exists only where the\nlive look diverges from the active theme, so a newly opened theme has none.\n\n## Saving\n\nIn the Theme panel:\n\n- **Save** captures the look on screen into the open theme. Your colors and type\n go in as part of it, so there is nothing to save first.\n- **Save As** names a new theme. Use it for your first save and for forking.\n\nComponent editors keep their own unsaved state. If one or more components are\nwaiting when you use **Save**, **Save As**, or **Adopt**, the Theme panel offers\nto save all of them before continuing. You can accept once instead of visiting\neach component, or cancel to review them individually. A component editor's\n**Save As** creates a reusable component preset.\n\nNames are tidied to lowercase with hyphens, so \"My Brand!\" becomes `my-brand`,\nand a leading underscore is dropped: those names are reserved for the buffer.\n**Motion Proto** is the built-in theme and is read-only. You can always return\nto it, and the editor never overwrites it, so start your own with **Save As**.\n\n## Switching\n\n**Load**—or clicking the active theme's name—opens the Theme Picker. Picking a\ntheme shows it on the page as a preview with nothing written to disk, so you can\ntry each look and compare. **Save** in that window opens and adopts the previewed\ntheme in one step: the active pointer changes, the buffers clear, components it\ndoes not carry fall through to their defaults, the editor works on it, and\nproduction ships it. **Cancel** returns you to where you were. Previewing alone\nnever changes what your site ships.\n\n**Colors and type only. Keep my shapes.** narrows the load to the palette and\nthe fonts: your component settings stay as they are and the theme you have open\nstays open. Saved colors and type files are listed there too, marked *colors &\ntype*, and picking one is always that narrower load.\n\n## Shipping\n\n**Adopt**, in the Theme panel, is the \"ship it\" step, and it ships the whole\nlook. It saves the open theme, then bakes that theme into\n`src/live-tokens/data/tokens.generated.css`, which your build bundles alongside\n`tokens.css`: the colors and type plus every component the theme carries. Fonts\nregenerate to match. The line under the theme name says whether production is\nrunning this theme.\n\nProduction is one saved theme, so nothing else publishes. Trying a look, moving\na token, saving a theme: all of it leaves the generated CSS alone until you\nAdopt. A component editor's Adopt runs the same whole-look step, because a\ncomponent never ships alone. Adopting while Motion Proto is open saves your look\nas a theme of your own first, since the built-in one is read-only.\n\nProduction builds (`npm run build`) ship only that plain CSS and your\ncomponents. No editor, no JSON loading, no runtime indirection.\n\n## Keeping your work safe\n\nEverything under `src/live-tokens/data/` is plain JSON, so commit it. Themes show\nup as readable diffs you can review per branch, and the buffer shows up as the\nwork you have not saved into a theme yet. Nothing is backed up anywhere else:\ngit is your safety net. To experiment freely, **Save As** a new name first, then\nedit.\n\n## Where to go next\n\n- **[Where themes live](where-themes-live.md)**: the files behind all of this,\n and what writes each one.\n- **[Creating components](creating-components.md)**: make your own components\n editable in the same editor.\n",
10
+ "where-themes-live": "# Where themes live\n\nEverything the editor writes is plain JSON and CSS inside your project. There\nis no database and no hidden state: the files are the storage, and git is the\nhistory.\n\n## The data tree\n\n```\nsrc/live-tokens/data/\n themes/\n _active.json names the theme the editor has open\n _production.json names the theme your site ships\n default.json Motion Proto, the built-in look, rewritten at boot\n my-brand.json a saved theme: the whole look in one file\n colors-and-type/\n _working.json unsaved colors and type edits\n component-configs/\n button/\n default.json Button's shipped settings, derived at boot\n _working.json unsaved Button edits\n my-button.json a preset you saved from the Button editor\n tokens.generated.css the baked CSS your production build ships\nsrc/system/styles/\n tokens.css your token vocabulary, hand-authored, never written\n fonts.css font imports, rewritten when you Adopt\n```\n\nA saved theme carries the whole look by value: the colors and type plus a\nsetting for every component you changed. It depends on no other file, so\ndeleting anything else never breaks it.\n\n## What writes when\n\n- **Editing** changes the page through CSS variables. The editor keeps your\n edits in the browser as you work and writes them to the `_working.json`\n buffers when you save a component. When the Theme panel finds several dirty\n components, **Save all** writes those buffers together.\n- **Save** captures the buffers into the open theme's file. That file is the\n durable copy of your look; matching buffers are then removed.\n- **Load** clears the buffers and points `themes/_active.json` at the theme you\n picked. Live reads fall through to that file. Nothing else changes, so trying\n looks is free and ordinary switching changes only the pointer.\n- **Adopt** points `themes/_production.json` at the open theme, bakes it into\n `tokens.generated.css`, and rewrites `fonts.css` to match. It is the only\n action that changes what your site ships.\n\nThe `default.json` files are the shipped baseline. The editor derives them at\nboot and refreshes them when the package updates; it never saves your work\nover them.\n\nProjects upgraded from 0.48 may initially contain working files copied from the\nactive theme. On the first dev-server boot, exact copies are removed\nautomatically. Any file that differs is kept as unsaved work, so no migration\ncommand is required.\n\n## What to commit\n\nAll of it. The data tree is designed to live in git: themes diff readably, the\ntwo pointers say what is open and what ships, and a `_working.json` in a diff\nis exactly the work you have not yet saved into a theme. Nothing is backed up\nanywhere else.\n\n## Where to go next\n\n- **[Themes](themes-workflow.md)**: the workflow built on these files: saving,\n loading, and shipping.\n",
11
11
  };
@@ -167,6 +167,7 @@
167
167
  {@const protectedRow = isProtected(file)}
168
168
  <div
169
169
  class="load-item"
170
+ data-file-name={file.fileName}
170
171
  class:active={file.fileName === activeFileName}
171
172
  class:selected={file.fileName === selectedFileName}
172
173
  class:protected={protectedRow}
@@ -11,7 +11,7 @@
11
11
  SystemCascadePreset,
12
12
  } from '../core/themes/themeTypes';
13
13
  import { editorState, setFontStacks } from '../core/store/editorStore';
14
- import { applyFontStacks, SYSTEM_CASCADES } from '../core/fonts/fontLoader';
14
+ import { SYSTEM_CASCADES } from '../core/fonts/fontLoader';
15
15
 
16
16
  const SYSTEM_PRESETS: SystemCascadePreset[] = ['system-ui-sans', 'system-ui-serif', 'system-ui-mono'];
17
17
  // `cursive` and `fantasy` are CSS-spec generics whose rendering varies wildly
@@ -110,7 +110,6 @@
110
110
  function updateStack(variable: FontStackVariable, updater: (slots: FontStackSlot[]) => FontStackSlot[]) {
111
111
  const next = stacks.map((s) => (s.variable === variable ? { ...s, slots: updater([...s.slots]) } : s));
112
112
  setFontStacks(next);
113
- applyFontStacks(next, fontSourcesList);
114
113
  }
115
114
 
116
115
  function handleSelectChange(variable: FontStackVariable, index: number, value: string) {
@@ -274,7 +274,11 @@
274
274
  </script>
275
275
 
276
276
  {#if gradient}
277
- <div class="gradient-editor" class:has-pad={hasAside}>
277
+ <div
278
+ class="gradient-editor"
279
+ class:has-pad={hasAside}
280
+ data-token-variable={gradientSource.targetVariable}
281
+ >
278
282
  {#if sectionLabel || hasAside}
279
283
  <div class="editor-header editor-section-left">
280
284
  <span class="editor-section-label">{sectionLabel ?? ''}</span>
@@ -1,7 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { FontFamily, FontSource } from '../core/themes/themeTypes';
3
3
  import { editorState, setFontSources, transaction } from '../core/store/editorStore';
4
- import { applyFontSources, applyFontStacks } from '../core/fonts/fontLoader';
5
4
  import {
6
5
  buildSourceFromFontFaceText,
7
6
  buildSourceFromUrl,
@@ -69,8 +68,6 @@
69
68
 
70
69
  function commitSources(next: FontSource[]) {
71
70
  setFontSources(next);
72
- applyFontSources(next);
73
- applyFontStacks(fontStacksList, next);
74
71
  }
75
72
 
76
73
  /** One paste field → sniff whether it's @font-face or a URL/embed and
@@ -220,8 +217,6 @@
220
217
  s.fonts.sources = next;
221
218
  s.fonts.stacks = updatedStacks;
222
219
  });
223
- applyFontSources(next);
224
- applyFontStacks(updatedStacks, next);
225
220
  }
226
221
 
227
222
  /** Resolve a clickable target for the row. We prefer the human-readable
@@ -22,8 +22,12 @@
22
22
  exportTheme,
23
23
  importTheme,
24
24
  } from '../core/themes/themeService';
25
+ import {
26
+ THEME_APPLIED_EVENT,
27
+ type AppliedThemeDetail,
28
+ } from '../core/themes/themeDocumentSync';
25
29
  import { countComponentsOffLook, lookProductionState } from '../core/themes/lookSummary';
26
- import { previewTheme, previewColorsAndType, revertPreview } from '../core/preview/lookPreview';
30
+ import { commitPreview, previewTheme, previewColorsAndType, revertPreview } from '../core/preview/lookPreview';
27
31
  import {
28
32
  deleteColorsAndType,
29
33
  hydrateColorsAndType,
@@ -41,10 +45,17 @@
41
45
  } from '../core/themes/loadRows';
42
46
  import {
43
47
  listComponents,
48
+ componentConfigFromState,
49
+ writeWorkingComponentConfig,
44
50
  type ComponentSummary,
45
51
  } from '../core/components/componentConfigService';
46
52
  import { fontPairingLabel } from '../core/fonts/fontPairing';
47
- import { componentDirty, editorState, colorsAndTypeDirty } from '../core/store/editorStore';
53
+ import {
54
+ componentDirty,
55
+ editorState,
56
+ colorsAndTypeDirty,
57
+ markComponentSaved,
58
+ } from '../core/store/editorStore';
48
59
  import { openThemeSlug } from '../core/store/editorConfigStore';
49
60
  import { editorView } from '../core/store/editorViewStore';
50
61
  import {
@@ -53,12 +64,14 @@
53
64
  bumpProductionRevision,
54
65
  liveMovedSinceBake,
55
66
  productionTheme,
67
+ bumpComponentActiveRevision,
56
68
  } from '../core/productionPulse';
57
69
  import { flashStatus } from '../core/flashStatus';
58
70
  import UIInfoPopover from './UIInfoPopover.svelte';
59
71
  import UIPillButton from './UIPillButton.svelte';
60
72
  import FileLoadList from './FileLoadList.svelte';
61
73
  import FilePill from './FilePill.svelte';
74
+ import UIDialog from './UIDialog.svelte';
62
75
  import SaveAsDialog from '../component-editor/scaffolding/SaveAsDialog.svelte';
63
76
 
64
77
  interface Props {
@@ -75,6 +88,9 @@
75
88
  let rows = $derived(buildLoadRows(files, colorsFiles));
76
89
  let showFileList = $state(false);
77
90
  let saveAsDialog = $state(false);
91
+ let saveComponentsDialog = $state(false);
92
+ let pendingComponentSave: (() => Promise<void>) | null = null;
93
+ let pendingComponentCount = $state(0);
78
94
  let saveStatus: 'idle' | 'saving' | 'saved' | 'error' = $state('idle');
79
95
 
80
96
  let currentDisplayName = $state('Motion Proto');
@@ -171,6 +187,19 @@
171
187
  await refreshProduction();
172
188
  });
173
189
 
190
+ // A consumer can load a theme from the host while this iframe stays open.
191
+ // The cross-document bridge hydrates this document's typed store; keep the
192
+ // panel's local identity/summary state on that same payload as well.
193
+ onMount(() => {
194
+ const handleThemeApplied = (event: Event) => {
195
+ const { result } = (event as CustomEvent<AppliedThemeDetail>).detail;
196
+ currentDisplayName = result.theme.name;
197
+ lookConfigs = result.theme.componentConfigs;
198
+ };
199
+ document.addEventListener(THEME_APPLIED_EVENT, handleThemeApplied);
200
+ return () => document.removeEventListener(THEME_APPLIED_EVENT, handleThemeApplied);
201
+ });
202
+
174
203
  // Re-read whenever an Adopt fires, here or in a component editor: it saves
175
204
  // the open theme and moves the production pointer, so the identity, the
176
205
  // component summary and the production state shown here all need to track.
@@ -188,10 +217,10 @@
188
217
  refreshComponents();
189
218
  });
190
219
 
191
- // A capture reads the server's live state, so the colors and type on screen
192
- // go to their buffer first — that is what makes Save and Adopt mean the look
193
- // in front of you. A component's edits live in its own editor's state, which
194
- // this panel cannot write, so those are the only ones a capture leaves behind.
220
+ // A capture reads the server's live state, so colors and type go to their
221
+ // buffer first. Component editors retain their own save boundary; when any
222
+ // are dirty, the theme panel offers to flush all of them in one explicit
223
+ // step before continuing.
195
224
  //
196
225
  // The gate is `colorsAndTypeDirty`, not history: history counts every entry,
197
226
  // components included, so it would flush the colors over component-only work.
@@ -200,20 +229,45 @@
200
229
  await persistColorsAndType(get(editorState), currentDisplayName);
201
230
  }
202
231
 
203
- function confirmUnsavedComponents(action: string): boolean {
204
- if (dirtyComponentCount === 0) return true;
205
- const n = dirtyComponentCount;
206
- return window.confirm(
207
- `${n === 1 ? '1 component has' : `${n} components have`} unsaved edits. Those stay out `
208
- + `until you save them in the component editor. ${action}`,
209
- );
232
+ async function flushComponents(displayName = currentDisplayName): Promise<void> {
233
+ const dirtyComponents = Object.entries(get(componentDirty))
234
+ .filter(([, dirty]) => dirty)
235
+ .map(([component]) => component);
236
+ if (dirtyComponents.length === 0) return;
237
+
238
+ const state = get(editorState);
239
+ await Promise.all(dirtyComponents.map((component) =>
240
+ writeWorkingComponentConfig(
241
+ component,
242
+ componentConfigFromState(state, component, displayName),
243
+ ),
244
+ ));
245
+ for (const component of dirtyComponents) markComponentSaved(component);
246
+ bumpComponentActiveRevision();
210
247
  }
211
248
 
212
- async function handleSave() {
213
- if (activeIsProtected) return;
214
- if (!confirmUnsavedComponents('Save the theme anyway?')) return;
249
+ function continueWithComponentChoice(action: () => Promise<void>): void {
250
+ const dirtyCount = Object.values(get(componentDirty)).filter(Boolean).length;
251
+ if (dirtyCount === 0) {
252
+ void action();
253
+ return;
254
+ }
255
+ pendingComponentCount = dirtyCount;
256
+ pendingComponentSave = action;
257
+ saveComponentsDialog = true;
258
+ }
259
+
260
+ async function confirmSaveAllComponents(): Promise<void> {
261
+ const action = pendingComponentSave;
262
+ pendingComponentSave = null;
263
+ saveComponentsDialog = false;
264
+ if (action) await action();
265
+ }
266
+
267
+ async function runSave(saveComponents: boolean) {
215
268
  saveStatus = 'saving';
216
269
  try {
270
+ if (saveComponents) await flushComponents();
217
271
  await flushColors();
218
272
  await saveActiveTheme(currentDisplayName);
219
273
  await refreshActive();
@@ -223,10 +277,21 @@
223
277
  }
224
278
  }
225
279
 
280
+ function handleSave() {
281
+ if (activeIsProtected) return;
282
+ continueWithComponentChoice(() => runSave(true));
283
+ }
284
+
226
285
  function openSaveAs() {
227
- if (!confirmUnsavedComponents('Save the theme anyway?')) return;
228
- showFileList = false;
229
- saveAsDialog = true;
286
+ continueWithComponentChoice(async () => {
287
+ try {
288
+ await flushComponents();
289
+ showFileList = false;
290
+ saveAsDialog = true;
291
+ } catch {
292
+ flashStatus(setSaveStatus, 'error');
293
+ }
294
+ });
230
295
  }
231
296
 
232
297
  async function confirmSaveAs(detail: { displayName: string; fileName: string }) {
@@ -266,10 +331,9 @@
266
331
  return 'Save this theme and ship it to production';
267
332
  });
268
333
 
269
- async function handleAdopt() {
334
+ function handleAdopt() {
270
335
  if (production.inProduction || adoptStatus === 'adopting') return;
271
- if (!confirmUnsavedComponents('Ship the theme anyway?')) return;
272
- await runAdopt();
336
+ continueWithComponentChoice(() => runAdopt(true));
273
337
  }
274
338
 
275
339
  /**
@@ -278,9 +342,10 @@
278
342
  * Adopt, and which file holds the look is bookkeeping they should not have
279
343
  * to think about, so it forks to a theme of their own first.
280
344
  */
281
- async function runAdopt() {
345
+ async function runAdopt(saveComponents = false) {
282
346
  adoptStatus = 'adopting';
283
347
  try {
348
+ if (saveComponents) await flushComponents();
284
349
  await flushColors();
285
350
  if (activeIsProtected) {
286
351
  const taken = new Set((await listThemes()).map((m) => m.fileName));
@@ -329,6 +394,13 @@
329
394
  previewLayer = null;
330
395
  }
331
396
 
397
+ function acceptPreview() {
398
+ commitPreview();
399
+ previewRow = null;
400
+ previewLook = null;
401
+ previewLayer = null;
402
+ }
403
+
332
404
  /** Paint whatever is selected, through the engine the mode calls for. A
333
405
  * theme's colors and type are the slice embedded in it, so one look
334
406
  * previews either way. */
@@ -386,26 +458,54 @@
386
458
  if (!row) return;
387
459
  if (unsavedEdits) {
388
460
  const ok = window.confirm(
389
- 'Loading a theme will reload the editor and discard unsaved changes. Continue?',
461
+ 'Loading a theme will replace the editor’s current state and discard unsaved changes. Continue?',
390
462
  );
391
463
  if (!ok) return;
392
464
  }
393
- // The window stays open until the page reloads: closing it would revert the
394
- // preview and flash the outgoing look while Apply is in flight.
465
+ // The exact theme being opened is already painted. Hand that preview to
466
+ // the store load without restoring the old look across the request.
467
+ acceptPreview();
468
+ let result: Awaited<ReturnType<typeof applyTheme>>;
395
469
  try {
396
- const result = await applyTheme(row.slug);
470
+ result = await applyTheme(row.slug);
397
471
  if (result.skippedComponents.length > 0) {
398
472
  window.alert(
399
473
  `Loaded "${row.name}". These components are not installed here, so their `
400
474
  + `saved settings were skipped:\n\n${result.skippedComponents.join(', ')}`,
401
475
  );
402
476
  }
403
- // applyTheme opens the theme: it clears working deltas and points
404
- // `themes/_active.json` at it. Reload to rehydrate through the resolver.
405
- window.location.reload();
477
+ currentDisplayName = result.theme.name;
478
+ lookConfigs = result.theme.componentConfigs;
479
+ showFileList = false;
406
480
  } catch (err) {
407
481
  window.alert(`Failed to load theme: ${(err as Error).message}`);
482
+ // The selection remains visible after a failed request; put its preview
483
+ // back so the dialog and page continue to agree.
484
+ previewRow = row;
485
+ previewLook = row.kind === 'look' ? await loadTheme(row.slug).catch(() => null) : null;
486
+ if (previewLook) await repaint();
487
+ return;
488
+ }
489
+
490
+ // The picker is a one-step choose-and-ship flow. Package/user presets are
491
+ // already saved documents, so adopting can publish them directly. The
492
+ // protected baseline cannot be production's editable document; mirror the
493
+ // normal Adopt behavior and fork it first when it is selected.
494
+ adoptStatus = 'adopting';
495
+ try {
496
+ if (row.slug === 'default') {
497
+ const taken = new Set((await listThemes()).map((m) => m.fileName));
498
+ await saveAsTheme(freshName('my-theme', taken), 'My Theme');
499
+ await refreshActive();
500
+ }
501
+ await adoptLook();
502
+ bumpProductionRevision();
503
+ flashStatus(setAdoptStatus, 'done');
504
+ } catch (err) {
505
+ window.alert(`Theme loaded, but could not be adopted: ${(err as Error).message}`);
506
+ flashStatus(setAdoptStatus, 'error', { durationMs: 3000 });
408
507
  }
508
+ await Promise.all([refreshFiles(), refreshComponents(), refreshProduction()]);
409
509
  }
410
510
 
411
511
  /**
@@ -554,6 +654,11 @@
554
654
  if (showFileList) refreshFiles();
555
655
  }
556
656
 
657
+ function openThemePicker() {
658
+ showFileList = true;
659
+ refreshFiles();
660
+ }
661
+
557
662
  function rowBadge(row: LoadRow) {
558
663
  return row.kind === 'layer'
559
664
  ? { label: 'colors & type', title: 'Holds colors and type only, no shapes' }
@@ -575,7 +680,7 @@
575
680
  <strong>Load</strong> opens the list. Picking a theme shows it on the page as a preview, so you can try each look with nothing written to disk. Pick another to compare, or <strong>Cancel</strong> to go back to where you were.
576
681
  </p>
577
682
  <p>
578
- <strong>Save</strong> opens the previewed theme: the editor works on it from then on, and components it does not carry go back to their defaults. Trying a theme never changes what your site ships.
683
+ <strong>Save</strong> opens and adopts the previewed theme: the editor works on it from then on and production ships it immediately. Components it does not carry go back to their defaults. Previewing and cancelling never change what your site ships.
579
684
  </p>
580
685
  <p>
581
686
  <strong>Colors and type only</strong> in that window takes the palette and the fonts and leaves your shapes and component settings alone.
@@ -584,7 +689,7 @@
584
689
  The <strong>active</strong> theme is the one the editor has open. <strong>Adopt</strong> saves it and ships it to production, colors and type plus every component you changed. The line under the name says whether production is running this theme.
585
690
  </p>
586
691
  <p>
587
- Save and Adopt both take the colors and type on screen as they are. Component edits are the exception: save those in the component's own editor first.
692
+ If components have unsaved edits, Save and Adopt offer to save all of them before continuing. You can cancel to review or save components individually.
588
693
  </p>
589
694
  <p>
590
695
  <strong>Motion Proto</strong> is the protected baseline. To start customizing, <strong>Save As</strong> a new theme first.
@@ -603,13 +708,21 @@
603
708
  </span>
604
709
  {/if}
605
710
  </div>
606
- <FilePill
607
- name={currentDisplayName}
608
- isProtected={activeIsProtected}
609
- protectedTitle="Protected default theme"
610
- title={currentDisplayName}
611
- style="display: flex;"
612
- />
711
+ <button
712
+ type="button"
713
+ class="theme-name-trigger"
714
+ onclick={openThemePicker}
715
+ title="Open the Theme Picker"
716
+ aria-label={`Open Theme Picker. Current theme: ${currentDisplayName}`}
717
+ >
718
+ <FilePill
719
+ name={currentDisplayName}
720
+ isProtected={activeIsProtected}
721
+ protectedTitle="Protected default theme"
722
+ title={currentDisplayName}
723
+ style="display: flex;"
724
+ />
725
+ </button>
613
726
  <span class="mfm-prod-status" class:applied={production.inProduction}>
614
727
  <i class="mfm-status-dot" aria-hidden="true"></i>
615
728
  <span>
@@ -668,7 +781,7 @@
668
781
  class="mfm-btn mfm-btn-row"
669
782
  class:active={showFileList}
670
783
  onclick={toggleFileList}
671
- title="Preview a theme, then save it to load it"
784
+ title="Open the Theme Picker"
672
785
  >
673
786
  <i class="fas fa-folder-open"></i>
674
787
  <span>Load…</span>
@@ -741,13 +854,29 @@
741
854
  style="display: none;"
742
855
  />
743
856
 
857
+ <UIDialog
858
+ bind:show={saveComponentsDialog}
859
+ title="Unsaved component edits"
860
+ cancelLabel="Cancel"
861
+ confirmLabel={pendingComponentCount === 1 ? 'Save component' : 'Save all components'}
862
+ onconfirm={confirmSaveAllComponents}
863
+ width="400px"
864
+ >
865
+ <p class="save-components-message">
866
+ {pendingComponentCount === 1
867
+ ? '1 component has unsaved edits.'
868
+ : `${pendingComponentCount} components have unsaved edits.`}
869
+ Save {pendingComponentCount === 1 ? 'it' : 'all of them'} and continue?
870
+ </p>
871
+ </UIDialog>
872
+
744
873
  <FileLoadList
745
874
  bind:show={showFileList}
746
- title="Load Theme"
875
+ title="Theme Picker"
747
876
  files={rows}
748
877
  activeFileName={activeRowId}
749
878
  selectedFileName={previewRow?.fileName ?? null}
750
- selectedBadge={{ label: 'Preview', title: 'Shown on the page now. Save to keep it.' }}
879
+ selectedBadge={{ label: 'Preview', title: 'Shown on the page now. Save to use and publish it.' }}
751
880
  {rowBadge}
752
881
  cancelLabel={previewRow ? 'Cancel' : 'Close'}
753
882
  confirmLabel={previewRow ? 'Save' : ''}
@@ -788,6 +917,13 @@
788
917
  />
789
918
 
790
919
  <style>
920
+ .save-components-message {
921
+ margin: 0;
922
+ color: var(--ui-text-secondary);
923
+ font-size: var(--ui-font-size-sm);
924
+ line-height: 1.5;
925
+ }
926
+
791
927
  .look-panel {
792
928
  --mfm-active: #5aa85e;
793
929
  --mfm-rail-neutral: var(--ui-border);
@@ -850,6 +986,34 @@
850
986
  color: var(--ui-text-tertiary);
851
987
  }
852
988
 
989
+ .theme-name-trigger {
990
+ display: block;
991
+ width: 100%;
992
+ padding: 0;
993
+ border: 0;
994
+ background: transparent;
995
+ color: inherit;
996
+ font: inherit;
997
+ text-align: inherit;
998
+ cursor: pointer;
999
+ }
1000
+
1001
+ .theme-name-trigger :global(.file-pill) {
1002
+ width: 100%;
1003
+ }
1004
+
1005
+ .theme-name-trigger:hover :global(.file-pill),
1006
+ .theme-name-trigger:focus-visible :global(.file-pill) {
1007
+ border-color: var(--ui-border-higher);
1008
+ box-shadow: inset 0 0 0 1px var(--ui-border-high);
1009
+ }
1010
+
1011
+ .theme-name-trigger:focus-visible {
1012
+ outline: 2px solid var(--ui-text-accent);
1013
+ outline-offset: 2px;
1014
+ border-radius: var(--ui-radius-md);
1015
+ }
1016
+
853
1017
  .mfm-badge {
854
1018
  display: inline-flex;
855
1019
  align-items: center;
@@ -55,7 +55,13 @@
55
55
  {#if show}
56
56
  <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions, a11y_no_static_element_interactions -->
57
57
  <div class="ui-dialog-backdrop" onclick={self(handleClose)}>
58
- <div class="ui-dialog" style="width: {width}; max-width: {width};">
58
+ <div
59
+ class="ui-dialog"
60
+ role="dialog"
61
+ aria-modal="true"
62
+ aria-label={title || 'Dialog'}
63
+ style="width: {width}; max-width: {width};"
64
+ >
59
65
  {#if title}
60
66
  <div class="ui-dialog-header">
61
67
  <h3 class="ui-dialog-title">{title}</h3>
@@ -1,10 +1,8 @@
1
1
  <script lang="ts">
2
- import { run } from 'svelte/legacy';
3
-
4
- import { onMount, onDestroy } from 'svelte';
2
+ import { onMount, onDestroy, untrack } from 'svelte';
5
3
  import { resolveAliasChain } from '../core/palettes/tokenRegistry';
6
4
  import { editorState } from '../core/store/editorStore';
7
- import { CSS_VAR_CHANGE_EVENT } from '../core/cssVarSync';
5
+ import { CSS_VARS_CHANGE_EVENT, type CssVarsChangeDetail } from '../core/cssVarSync';
8
6
  import type { FontFamily, FontSource, FontSourceKind } from '../core/themes/themeTypes';
9
7
  import UITokenSelector from './UITokenSelector.svelte';
10
8
  import UIOptionList from './UIOptionList.svelte';
@@ -93,8 +91,8 @@
93
91
  }
94
92
 
95
93
  function handleVarChange(e: Event) {
96
- const detail = (e as CustomEvent<{ name: string }>).detail;
97
- if (detail?.name?.startsWith('--font-')) readResolved();
94
+ const names = (e as CustomEvent<CssVarsChangeDetail>).detail?.names ?? [];
95
+ if (names.some((name) => name.startsWith('--font-'))) readResolved();
98
96
  }
99
97
 
100
98
  function initFromCurrent() {
@@ -160,19 +158,21 @@
160
158
 
161
159
  // Re-derive `chosenKey` / `chosenFamilyId` when `variable` changes (the
162
160
  // VariantGroup tabs view reuses the same selector instance across states).
163
- let lastSeenVariable: string | null = $state(null);
164
- run(() => {
165
- if (variable !== lastSeenVariable) {
166
- lastSeenVariable = variable;
161
+ $effect(() => {
162
+ // A tab can reuse this instance with a different variable. Track only the
163
+ // prop; initialization writes local selector state and is intentionally
164
+ // untracked to prevent a self-triggering effect.
165
+ variable;
166
+ untrack(() => {
167
167
  initFromCurrent();
168
- }
168
+ });
169
169
  });
170
170
 
171
171
  onMount(() => {
172
- document.addEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
172
+ document.addEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
173
173
  });
174
174
  onDestroy(() => {
175
- document.removeEventListener(CSS_VAR_CHANGE_EVENT, handleVarChange);
175
+ document.removeEventListener(CSS_VARS_CHANGE_EVENT, handleVarChange);
176
176
  });
177
177
 
178
178
  let activeLabel = $derived(chosenFamilyId