@motion-proto/live-tokens 0.55.0 → 0.56.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.
Files changed (38) hide show
  1. package/.claude/skills/{live-tokens-adjust-shape-space → live-tokens-adjust-geometry}/SKILL.md +5 -5
  2. package/.claude/skills/live-tokens-build-page/SKILL.md +2 -0
  3. package/.claude/skills/live-tokens-create-component/SKILL.md +20 -357
  4. package/.claude/skills/live-tokens-create-component/references/fixed-overlays.md +3 -0
  5. package/.claude/skills/live-tokens-create-component/references/intrinsics.md +58 -0
  6. package/.claude/skills/live-tokens-create-component/references/linked-siblings.md +70 -0
  7. package/.claude/skills/live-tokens-generate-theme/SKILL.md +79 -98
  8. package/.claude/skills/live-tokens-generate-theme/references/named-themes.md +18 -0
  9. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +88 -0
  10. package/.claude/skills/live-tokens-pick-component/SKILL.md +4 -1
  11. package/CHANGELOG.md +108 -0
  12. package/README.md +20 -7
  13. package/bin/cli.mjs +41 -3
  14. package/bin/set-fonts.mjs +280 -0
  15. package/dist-plugin/adjust/index.d.cts +2 -2
  16. package/dist-plugin/adjust/index.d.ts +2 -2
  17. package/dist-plugin/fontPairing/index.cjs +411 -0
  18. package/dist-plugin/fontPairing/index.d.cts +109 -0
  19. package/dist-plugin/fontPairing/index.d.ts +109 -0
  20. package/dist-plugin/fontPairing/index.js +320 -0
  21. package/dist-plugin/generateColorsAndType/index.d.cts +2 -2
  22. package/dist-plugin/generateColorsAndType/index.d.ts +2 -2
  23. package/dist-plugin/index-4N-Orzzi.d.cts +3 -0
  24. package/dist-plugin/index-4N-Orzzi.d.ts +3 -0
  25. package/dist-plugin/{index-DpTIRZ2H.d.cts → themeTypes-BAqtv4XO.d.cts} +1 -3
  26. package/dist-plugin/{index-DpTIRZ2H.d.ts → themeTypes-BAqtv4XO.d.ts} +1 -3
  27. package/package.json +3 -2
  28. package/src/editor/core/fonts/applyFontPairing.ts +157 -0
  29. package/src/editor/core/fonts/fontPairing.ts +13 -6
  30. package/src/editor/core/fonts/googleFontsUrl.ts +123 -0
  31. package/src/editor/core/fonts/weightCoverage.ts +92 -0
  32. package/src/editor/docs/content/themes-workflow.md +39 -0
  33. package/src/editor/docs/content.generated.ts +1 -1
  34. package/src/editor/ui/ColorEditPanel.svelte +16 -1
  35. package/src/editor/ui/PaletteEditor.svelte +124 -31
  36. package/src/editor/ui/ProjectFontsSection.svelte +24 -36
  37. package/src/editor/ui/palette/OverridesPanel.svelte +5 -2
  38. package/src/editor/ui/palette/PaletteBase.svelte +13 -0
@@ -0,0 +1,123 @@
1
+ import { parseFontFaceText } from './fontParse';
2
+
3
+ export interface CssResponse {
4
+ ok: boolean;
5
+ status: number;
6
+ text: () => Promise<string>;
7
+ }
8
+
9
+ export type CssFetcher = (url: string) => Promise<CssResponse>;
10
+
11
+ export interface ResolvedGoogleFont {
12
+ /** As Google spells it in the returned CSS, not as the caller typed it. */
13
+ name: string;
14
+ url: string;
15
+ weights: number[];
16
+ italics: boolean;
17
+ probeUrl: string;
18
+ }
19
+
20
+ /** The shipped `--font-weight-*` scale runs 100 to 900 in hundreds, so probing
21
+ * that ladder discovers every weight a theme can ask for. */
22
+ const LADDER = [100, 200, 300, 400, 500, 600, 700, 800, 900];
23
+
24
+ function familyParam(name: string): string {
25
+ return name.trim().replace(/\s+/g, '+');
26
+ }
27
+
28
+ function url(name: string, spec?: string): string {
29
+ const family = spec ? `${familyParam(name)}:${spec}` : familyParam(name);
30
+ return `https://fonts.googleapis.com/css2?family=${family}&display=swap`;
31
+ }
32
+
33
+ /**
34
+ * Ask for every weight in both styles. Google silently drops the ones a family
35
+ * does not have and answers 200 anyway, so the returned CSS is a census of the
36
+ * family rather than a yes/no on the request. A 400 here means the family
37
+ * itself is not on Google Fonts.
38
+ */
39
+ export function discoveryUrl(name: string): string {
40
+ const pairs = [
41
+ ...LADDER.map((w) => `0,${w}`),
42
+ ...LADDER.map((w) => `1,${w}`),
43
+ ];
44
+ return url(name, `ital,wght@${pairs.join(';')}`);
45
+ }
46
+
47
+ function isContiguous(weights: number[]): boolean {
48
+ return weights.length > 1 && weights.every((w, i) => i === 0 || w - weights[i - 1] === 100);
49
+ }
50
+
51
+ /** The narrowest URL that still delivers everything the family has. A range
52
+ * serves one variable file; an enumeration serves one file per weight. */
53
+ export function persistUrlFor(name: string, weights: number[], italics: boolean): string {
54
+ if (weights.length === 0 || (weights.length === 1 && weights[0] === 400 && !italics)) {
55
+ return url(name);
56
+ }
57
+ if (isContiguous(weights)) {
58
+ const range = `${weights[0]}..${weights[weights.length - 1]}`;
59
+ return url(name, italics ? `ital,wght@0,${range};1,${range}` : `wght@${range}`);
60
+ }
61
+ const spec = italics
62
+ ? `ital,wght@${[...weights.map((w) => `0,${w}`), ...weights.map((w) => `1,${w}`)].join(';')}`
63
+ : `wght@${weights.join(';')}`;
64
+ return url(name, spec);
65
+ }
66
+
67
+ function censusFrom(css: string, requested: string): { name: string; weights: number[]; italics: boolean } {
68
+ const families = parseFontFaceText(css);
69
+ if (families.length === 0) {
70
+ throw new Error(`Google Fonts returned no @font-face rules for "${requested}".`);
71
+ }
72
+ const family = families[0];
73
+ return {
74
+ name: family.name,
75
+ weights: family.weights ?? [],
76
+ italics: family.italics === true,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Verify a family exists on Google Fonts and settle on the URL to persist.
82
+ * Two requests in the common case: one to take the census, one to confirm the
83
+ * narrower URL built from it. A range that the family's axis cannot serve
84
+ * answers 400, so the enumerated form is the fallback rather than the guess.
85
+ */
86
+ export async function resolveGoogleFont(name: string, fetcher: CssFetcher): Promise<ResolvedGoogleFont> {
87
+ const probeUrl = discoveryUrl(name);
88
+ const probe = await fetcher(probeUrl);
89
+ if (!probe.ok) {
90
+ throw new Error(
91
+ `"${name}" is not on Google Fonts (the API answered ${probe.status}). ` +
92
+ `Check the spelling against fonts.google.com.`,
93
+ );
94
+ }
95
+ const census = censusFrom(await probe.text(), name);
96
+
97
+ const candidates = [persistUrlFor(census.name, census.weights, census.italics)];
98
+ const enumerated = census.weights.length > 0
99
+ ? url(
100
+ census.name,
101
+ census.italics
102
+ ? `ital,wght@${[...census.weights.map((w) => `0,${w}`), ...census.weights.map((w) => `1,${w}`)].join(';')}`
103
+ : `wght@${census.weights.join(';')}`,
104
+ )
105
+ : null;
106
+ if (enumerated && !candidates.includes(enumerated)) candidates.push(enumerated);
107
+ const bare = url(census.name);
108
+ if (!candidates.includes(bare)) candidates.push(bare);
109
+
110
+ for (const candidate of candidates) {
111
+ const res = await fetcher(candidate);
112
+ if (!res.ok) continue;
113
+ const served = censusFrom(await res.text(), census.name);
114
+ return {
115
+ name: census.name,
116
+ url: candidate,
117
+ weights: served.weights,
118
+ italics: served.italics,
119
+ probeUrl,
120
+ };
121
+ }
122
+ throw new Error(`Could not build a working Google Fonts URL for "${census.name}".`);
123
+ }
@@ -0,0 +1,92 @@
1
+ import type { FontStackVariable } from '../themes/themeTypes';
2
+ import { inferFontFamilyVariable } from './fontWeightAvailability';
3
+
4
+ export interface WeightRequirement {
5
+ variable: string;
6
+ stack: FontStackVariable;
7
+ weight: number;
8
+ }
9
+
10
+ export interface FaceCoverage {
11
+ stack: FontStackVariable;
12
+ family: string;
13
+ required: number[];
14
+ available: number[];
15
+ missing: number[];
16
+ italics: boolean;
17
+ }
18
+
19
+ const STACK_REF = /var\(\s*(--font-(?:display|sans|serif|mono))\s*\)/;
20
+ const VAR_REF = /var\(\s*(--[a-z0-9-]+)/i;
21
+
22
+ function declarations(css: string): Map<string, string> {
23
+ const decls = new Map<string, string>();
24
+ for (const [, name, value] of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;{}]+);/gi)) {
25
+ decls.set(name, value.trim());
26
+ }
27
+ return decls;
28
+ }
29
+
30
+ /**
31
+ * Every weight the token contract asks of each font stack, read off the
32
+ * `-font-family` / `-font-weight` pairing that typography tokens follow.
33
+ * `overrides` takes a theme's own `cssVariables`, so component tokens count
34
+ * the same as the shipped text styles.
35
+ */
36
+ export function requiredWeights(
37
+ tokensCss: string,
38
+ overrides: Record<string, string> = {},
39
+ ): WeightRequirement[] {
40
+ const decls = declarations(tokensCss);
41
+ for (const [name, value] of Object.entries(overrides)) decls.set(name, String(value).trim());
42
+
43
+ const scale = new Map<string, number>();
44
+ for (const [name, value] of decls) {
45
+ if (name.startsWith('--font-weight-') && /^\d+$/.test(value)) scale.set(name, Number(value));
46
+ }
47
+
48
+ const seen = new Set<string>();
49
+ const out: WeightRequirement[] = [];
50
+ for (const [name, value] of decls) {
51
+ const familyVariable = inferFontFamilyVariable(name);
52
+ if (!familyVariable) continue;
53
+ const stack = decls.get(familyVariable)?.match(STACK_REF)?.[1] as FontStackVariable | undefined;
54
+ if (!stack) continue;
55
+
56
+ const weight = /^\d+$/.test(value) ? Number(value) : scale.get(value.match(VAR_REF)?.[1] ?? '');
57
+ if (weight === undefined) continue;
58
+
59
+ const key = `${stack}:${weight}`;
60
+ if (seen.has(key)) continue;
61
+ seen.add(key);
62
+ out.push({ variable: name, stack, weight });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ export interface CoveredFace {
68
+ name: string;
69
+ weights: number[];
70
+ italics: boolean;
71
+ }
72
+
73
+ export function weightCoverage(
74
+ requirements: WeightRequirement[],
75
+ faces: Partial<Record<FontStackVariable, CoveredFace>>,
76
+ ): FaceCoverage[] {
77
+ const out: FaceCoverage[] = [];
78
+ for (const [stack, face] of Object.entries(faces) as [FontStackVariable, CoveredFace][]) {
79
+ const required = [...new Set(requirements.filter((r) => r.stack === stack).map((r) => r.weight))].sort(
80
+ (a, b) => a - b,
81
+ );
82
+ out.push({
83
+ stack,
84
+ family: face.name,
85
+ required,
86
+ available: face.weights,
87
+ missing: required.filter((w) => !face.weights.includes(w)),
88
+ italics: face.italics,
89
+ });
90
+ }
91
+ return out;
92
+ }
@@ -35,6 +35,45 @@ A theme is a document, and the editor works the way any editor does.
35
35
  Absence is the answer for anything untouched: a buffer exists only where the
36
36
  live look diverges from the active theme, so a newly opened theme has none.
37
37
 
38
+ ## Fonts
39
+
40
+ Type is part of the look, so it saves, loads and ships with the theme rather
41
+ than on its own. Four named stacks carry it:
42
+
43
+ | Stack | Used by |
44
+ |---|---|
45
+ | `--font-display` | headings |
46
+ | `--font-sans` | body text and most UI |
47
+ | `--font-serif` | anywhere you ask for it |
48
+ | `--font-mono` | code |
49
+
50
+ Each stack is a family followed by its fallbacks, so a page still reads while a
51
+ web font loads, and still reads if it never does. **Project fonts**, in the
52
+ Colors and type editor, is where families come from: type a Google Fonts family
53
+ name and the editor checks it, or paste a fonts URL, an embed tag, or your own
54
+ `@font-face` rules. Removing a family puts the stack back on its fallbacks.
55
+
56
+ You can also set both faces at once from the command line:
57
+
58
+ ```bash
59
+ npx live-tokens set-fonts fonts.json
60
+ ```
61
+
62
+ with a brief naming the families:
63
+
64
+ ```json
65
+ { "display": "Fraunces", "body": "Nunito Sans" }
66
+ ```
67
+
68
+ It checks each family against Google Fonts, works out the weights that family
69
+ actually has, and binds it to its stack. Like every other edit, the result lands
70
+ in the buffer, so **Save** keeps it. In Claude Code, asking for a font pairing in
71
+ plain English runs the same command.
72
+
73
+ A font is only requested by the browser once something on the page uses it, so
74
+ carrying a family you no longer reference costs nothing at load. Adopting is
75
+ what writes the font imports your site ships, into `fonts.css`.
76
+
38
77
  ## Saving
39
78
 
40
79
  In the Theme panel:
@@ -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.** Three curves shape the ramp, in stack order: Hue, Saturation,\n Lightness. Drag the handles to bias it warmer or cooler, more or less\n saturated, darker or lighter. Hue drifts the ramp's temperature without\n moving contrast, because OKLCH hue rotation is close to lightness-preserving.\n It holds ±45 degrees; a bigger shift belongs on the base colour or the\n harmony axis.\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, in one file. It carries the\nname the look ships under, whether production is running it, and **Adopt**.\nTwo 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 have an unsaved edit that has not\n been saved into the theme, 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.\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, the editor\nworks on it, and production ships it. **Cancel** returns you to where you were.\nPreviewing alone 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",
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, in one file. It carries the\nname the look ships under, whether production is running it, and **Adopt**.\nTwo 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 have an unsaved edit that has not\n been saved into the theme, 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.\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## Fonts\n\nType is part of the look, so it saves, loads and ships with the theme rather\nthan on its own. Four named stacks carry it:\n\n| Stack | Used by |\n|---|---|\n| `--font-display` | headings |\n| `--font-sans` | body text and most UI |\n| `--font-serif` | anywhere you ask for it |\n| `--font-mono` | code |\n\nEach stack is a family followed by its fallbacks, so a page still reads while a\nweb font loads, and still reads if it never does. **Project fonts**, in the\nColors and type editor, is where families come from: type a Google Fonts family\nname and the editor checks it, or paste a fonts URL, an embed tag, or your own\n`@font-face` rules. Removing a family puts the stack back on its fallbacks.\n\nYou can also set both faces at once from the command line:\n\n```bash\nnpx live-tokens set-fonts fonts.json\n```\n\nwith a brief naming the families:\n\n```json\n{ \"display\": \"Fraunces\", \"body\": \"Nunito Sans\" }\n```\n\nIt checks each family against Google Fonts, works out the weights that family\nactually has, and binds it to its stack. Like every other edit, the result lands\nin the buffer, so **Save** keeps it. In Claude Code, asking for a font pairing in\nplain English runs the same command.\n\nA font is only requested by the browser once something on the page uses it, so\ncarrying a family you no longer reference costs nothing at load. Adopting is\nwhat writes the font imports your site ships, into `fonts.css`.\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, the editor\nworks on it, and production ships it. **Cancel** returns you to where you were.\nPreviewing alone 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
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. It depends on no other file, so deleting\nanything 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
  };
@@ -8,6 +8,9 @@
8
8
 
9
9
  interface Props {
10
10
  title?: string | null;
11
+ /** Ties the panel to the swatch it was opened from, when it renders docked
12
+ * beneath that swatch's row. */
13
+ highlighted?: boolean;
11
14
  showRemoveOverride?: boolean;
12
15
  /** Hide the confirm/cancel actions for live-apply consumers (Colors view
13
16
  * readout) where edits commit through the store as they happen. */
@@ -37,6 +40,7 @@
37
40
 
38
41
  let {
39
42
  title = null,
43
+ highlighted = false,
40
44
  showRemoveOverride = false,
41
45
  hideActions = false,
42
46
  hidePreview = false,
@@ -133,7 +137,7 @@
133
137
  }
134
138
  </script>
135
139
 
136
- <div class="hsl-panel">
140
+ <div class="hsl-panel" class:highlighted>
137
141
  <div class="hsl-panel-header">
138
142
  {#if !hidePreview}
139
143
  <div class="hsl-preview" style="background: {previewHex}"></div>
@@ -250,6 +254,17 @@
250
254
  border-radius: var(--ui-radius-md);
251
255
  }
252
256
 
257
+ /* Docked to a swatch: the border thickens to the weight of that swatch's
258
+ selection ring, so the pair reads as one object. */
259
+ .hsl-panel.highlighted {
260
+ border-width: 2px;
261
+ border-color: var(--ui-border-higher);
262
+ }
263
+
264
+ .hsl-panel.highlighted .hsl-panel-title {
265
+ color: var(--ui-text-primary);
266
+ }
267
+
253
268
  .hsl-panel-header {
254
269
  display: flex;
255
270
  align-items: center;
@@ -207,8 +207,19 @@
207
207
  openSession();
208
208
  }
209
209
 
210
- function handlePaletteClick(ps: { label: string; lightness: number; index: number }) {
210
+ function handlePaletteClick(ps: { label: string; index: number; anchored: boolean }) {
211
211
  const k = paletteStepKey(ps.label);
212
+ // The anchored step is not a copy of the base color, it IS the base color:
213
+ // the anchor pins the curves through it. So editing it edits the base, and
214
+ // the header swatch and the whole ramp follow. An override here would fork
215
+ // the two apart and quietly break the guarantee the anchor makes.
216
+ if (ps.anchored) {
217
+ if (isEditingBase) { confirmEdit(); return; }
218
+ startBaseEdit();
219
+ // After openSession, so cancel and undo both put it back.
220
+ if (k in overrides) removeOverride(k);
221
+ return;
222
+ }
212
223
  if (editingKey === k) {
213
224
  confirmEdit();
214
225
  return;
@@ -505,6 +516,10 @@
505
516
  let overridesHex = $derived(Object.fromEntries(Object.entries(overrides).map(([k, v]) => [k, toHex(v)])));
506
517
 
507
518
  let isEditingBase = $derived(isBaseEdit(editing));
519
+ // Naming the step the base sits on is what says the header swatch and that
520
+ // ramp swatch are one value, not two that happen to match.
521
+ let anchoredStep = $derived(paletteComputed.find(ps => ps.anchored) ?? null);
522
+ let basePanelTitle = $derived(anchoredStep ? `Base Color \u203A ${anchoredStep.label}` : 'Base Color');
508
523
  let editingColor = $derived(isEditingBase ? baseColor : editingDraft);
509
524
  let editingStepInfo = $derived((() => {
510
525
  if (!editingKey || isEditingBase) return null;
@@ -520,11 +535,28 @@
520
535
  return null;
521
536
  })());
522
537
  let panelOpen = $derived(editingKey !== null && (isEditingBase || (editingDraft !== null && editingStepInfo !== null)));
523
- let editPanelTitle = $derived(isEditingBase
524
- ? 'Base Color'
525
- : editingStepInfo
526
- ? `${editingStepInfo.scale} \u203A ${editingStepInfo.step}`
527
- : null);
538
+ // The panel docks under the row that owns the step being edited, so these
539
+ // place it: a column for the palette caret, a row slot for the derived scales.
540
+ let editingPaletteIndex = $derived.by(() => {
541
+ if (!panelOpen || isEditingBase) return null;
542
+ const i = paletteComputed.findIndex(ps => ps.key === editingKey);
543
+ return i === -1 ? null : i;
544
+ });
545
+ let editingDerivedRow = $derived.by(() => {
546
+ if (!panelOpen || isEditingBase || editingPaletteIndex !== null) return null;
547
+ const scale = scales.find(s => s.title === editingStepInfo?.scale);
548
+ if (!scale) return null;
549
+ return scale.isText ? 'text' : 'solid';
550
+ });
551
+ let editPanelTitle = $derived(editingStepInfo ? `${editingStepInfo.scale} \u203A ${editingStepInfo.step}` : null);
552
+ // The reveal's outro needs something to render: closing the panel resets
553
+ // `editing` to idle, and every value below it goes null in the same tick, so a
554
+ // live read would slide an empty box shut. Held until the next edit replaces it.
555
+ let dock: { title: string | null; color: Oklch; paletteIndex: number | null } | null = $state(null);
556
+ $effect(() => {
557
+ if (!panelOpen || isEditingBase || !editingColor) return;
558
+ dock = { title: editPanelTitle, color: editingColor, paletteIndex: editingPaletteIndex };
559
+ });
528
560
  $effect(() => {
529
561
  // Touch each input so the effect tracks them (resnapScales reads indirectly).
530
562
  void baseColor;
@@ -537,6 +569,24 @@
537
569
  });
538
570
  </script>
539
571
 
572
+ {#snippet stepEditPanel()}
573
+ {#if dock}
574
+ <ColorEditPanel
575
+ title={dock.title}
576
+ highlighted
577
+ showRemoveOverride
578
+ hue={dock.color.h}
579
+ chroma={dock.color.c}
580
+ lightness={dock.color.l * 100}
581
+ onHueChromaChange={(h, c, l) => handleColorChange({ l: l / 100, c, h })}
582
+ onConfirm={confirmEdit}
583
+ onCancel={cancelEdit}
584
+ onRemoveOverride={() => editingKey && removeOverride(editingKey)}
585
+ onSliderStart={() => beginSliderGesture(`edit ${label} ${editingKey ?? 'color'}`)}
586
+ />
587
+ {/if}
588
+ {/snippet}
589
+
540
590
  <div class="palette-editor" bind:this={rootEl} style="--editor-base: {toHex(baseColor)}">
541
591
  <PaletteBase
542
592
  {label}
@@ -544,6 +594,7 @@
544
594
  {baseColor}
545
595
  {isEditingBase}
546
596
  pinnedOpen={paletteEditorOpen}
597
+ selected={isEditingBase}
547
598
  {copiedKey}
548
599
  onStartEdit={startBaseEdit}
549
600
  onCopyBaseHex={copyHex}
@@ -573,7 +624,7 @@
573
624
  <div class="swatch-grid" style="--swatch-cols: {paletteStepLightness.length + 2}">
574
625
  <div class="base-panel" style="grid-column: 2 / {paletteStepLightness.length + 2}">
575
626
  <ColorEditPanel
576
- title={isEditingBase ? editPanelTitle : 'Base Color'}
627
+ title={basePanelTitle}
577
628
  showRemoveOverride={false}
578
629
  hideActions={!isEditingBase}
579
630
  hidePreview
@@ -617,21 +668,21 @@
617
668
  </div>
618
669
  {#each paletteComputed as ps}
619
670
  <div class="step-column">
620
- <button class="step-label copyable-label" class:copied={copiedLabelKey === ps.key} type="button" onclick={(e) => copyVarName(ps.key, `--color-${cssNamespace}-${ps.label}`, e)}>
671
+ <button class="step-label copyable-label" class:copied={copiedLabelKey === ps.key} class:selected={editingKey === ps.key || (isEditingBase && ps.anchored)} type="button" onclick={(e) => copyVarName(ps.key, `--color-${cssNamespace}-${ps.label}`, e)}>
621
672
  {copiedLabelKey === ps.key ? 'copied!' : ps.label}
622
673
  </button>
623
674
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
624
675
  <div
625
676
  class="swatch gray-swatch"
626
- class:active={editingKey === ps.key}
677
+ class:active={editingKey === ps.key || (isEditingBase && ps.anchored)}
627
678
  class:overridden={ps.key in overrides}
628
679
  class:anchored={ps.anchored}
629
680
  title={ps.anchored ? 'Base color' : undefined}
630
681
  style="background: {ps.effective}"
631
- onclick={() => handlePaletteClick({ label: ps.label, lightness: ps.lightness, index: ps.index })}
682
+ onclick={() => handlePaletteClick(ps)}
632
683
  role="button"
633
684
  tabindex="0"
634
- onkeydown={(e) => e.key === 'Enter' && handlePaletteClick({ label: ps.label, lightness: ps.lightness, index: ps.index })}
685
+ onkeydown={(e) => e.key === 'Enter' && handlePaletteClick(ps)}
635
686
  >
636
687
  {#if ps.key in overrides}
637
688
  <span class="override-lock" title="Palette override: this step is set by hand, not derived from the curve">
@@ -653,6 +704,17 @@
653
704
  </button>
654
705
  <div class="swatch gray-swatch bookend" style="background: #000000"></div>
655
706
  </div>
707
+
708
+ <UIReveal open={editingPaletteIndex !== null} style="grid-column: 2 / {paletteStepLightness.length + 2}">
709
+ <!-- The reveal has to own the grid slot, so the dock rebuilds the
710
+ ramp's tracks inside it: the caret is then placed on the step's
711
+ own column and points at it at any width, no arithmetic. -->
712
+ <div class="dock" style="--dock-cols: {paletteStepLightness.length}">
713
+ <div class="edit-caret" style="grid-column: {(dock?.paletteIndex ?? 0) + 1}"></div>
714
+ <div class="docked-panel">{@render stepEditPanel()}</div>
715
+ </div>
716
+ </UIReveal>
717
+
656
718
  <UIReveal open={paletteEditorOpen} style="grid-column: 2 / {paletteStepLightness.length + 2}">
657
719
  <div class="curve-grid-span">
658
720
  {#if anchorUnlockPrompt && anchorToBase}
@@ -787,29 +849,19 @@
787
849
  {@render overridesPanel(scale, true, derivedHexForBase)}
788
850
  {/each}
789
851
  </div>
852
+ <UIReveal open={editingDerivedRow === 'text'}>
853
+ <div class="docked-panel">{@render stepEditPanel()}</div>
854
+ </UIReveal>
790
855
  <div class="scales-row">
791
856
  {#each scales.filter(s => !s.isText) as scale}
792
857
  {@render overridesPanel(scale, true, derivedHexForBase)}
793
858
  {/each}
794
859
  </div>
860
+ <UIReveal open={editingDerivedRow === 'solid'}>
861
+ <div class="docked-panel">{@render stepEditPanel()}</div>
862
+ </UIReveal>
795
863
  </div>
796
864
  </UIReveal>
797
-
798
- <!-- Color Edit Panel (non-base edits) -->
799
- {#if !isEditingBase && panelOpen && editingColor}
800
- <ColorEditPanel
801
- title={editPanelTitle}
802
- showRemoveOverride={!!editingKey}
803
- hue={editingColor.h}
804
- chroma={editingColor.c}
805
- lightness={editingColor.l * 100}
806
- onHueChromaChange={(h, c, l) => handleColorChange({ l: l / 100, c, h })}
807
- onConfirm={confirmEdit}
808
- onCancel={cancelEdit}
809
- onRemoveOverride={() => editingKey && removeOverride(editingKey)}
810
- onSliderStart={() => beginSliderGesture(`edit ${label} ${editingKey ?? 'color'}`)}
811
- />
812
- {/if}
813
865
  </div>
814
866
 
815
867
  <style>
@@ -937,6 +989,12 @@
937
989
  color: var(--ui-text-accent, var(--ui-text-primary));
938
990
  }
939
991
 
992
+ /* The name the docked panel's title repeats back. */
993
+ .step-label.copyable-label.selected {
994
+ color: var(--ui-text-primary);
995
+ font-weight: var(--ui-font-weight-semibold);
996
+ }
997
+
940
998
  /* Swatches */
941
999
 
942
1000
  .swatch {
@@ -1013,6 +1071,34 @@
1013
1071
  gap: var(--ui-space-8);
1014
1072
  }
1015
1073
 
1074
+ /* Same tracks and gutter as the ramp above, so a column here lands under the
1075
+ swatch of the same index. */
1076
+ .dock {
1077
+ display: grid;
1078
+ grid-template-columns: repeat(var(--dock-cols), minmax(0, 1fr));
1079
+ gap: var(--ui-space-4) var(--swatch-gap, var(--ui-space-4));
1080
+ }
1081
+
1082
+ /* Docked under the row it edits, pointed at the swatch it edits. */
1083
+ .docked-panel {
1084
+ grid-column: 1 / -1;
1085
+ min-width: 0;
1086
+ }
1087
+
1088
+ .edit-caret {
1089
+ display: flex;
1090
+ justify-content: center;
1091
+ align-items: flex-end;
1092
+ height: var(--ui-space-6);
1093
+ }
1094
+
1095
+ .edit-caret::after {
1096
+ content: '';
1097
+ border-left: var(--ui-space-6) solid transparent;
1098
+ border-right: var(--ui-space-6) solid transparent;
1099
+ border-bottom: var(--ui-space-6) solid var(--ui-border-higher);
1100
+ }
1101
+
1016
1102
  .anchor-unlock-notice {
1017
1103
  display: flex;
1018
1104
  align-items: center;
@@ -1057,10 +1143,17 @@
1057
1143
  border-color: var(--ui-border-high);
1058
1144
  }
1059
1145
 
1060
- .swatch.gray-swatch.active {
1061
- border-color: var(--ui-border-higher);
1062
- outline: 2px solid var(--ui-border-high);
1063
- outline-offset: 1px;
1146
+ /* A ring drawn inside the swatch, weight only: the editor stays greyscale, so
1147
+ selection has to out-weigh the anchored step's emphasized border rather
1148
+ than out-colour it. Insets, because a 3px outline would eat the column gap
1149
+ and a 3px border would resize the swatch mid-grid. The dark keylines either
1150
+ side of the white band keep it legible at both ends of the ramp. */
1151
+ .swatch.gray-swatch.active,
1152
+ .swatch.gray-swatch.anchored.active {
1153
+ border-color: var(--ui-surface-lowest);
1154
+ box-shadow:
1155
+ inset 0 0 0 3px var(--ui-text-primary),
1156
+ inset 0 0 0 4px var(--ui-surface-lowest);
1064
1157
  }
1065
1158
 
1066
1159
  /* The step the base color is placed at: taller, with an emphasized border. It