@motion-proto/live-tokens 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/live-tokens-build-page/SKILL.md +35 -0
- package/.claude/skills/live-tokens-create-component/SKILL.md +483 -0
- package/.claude/skills/live-tokens-pick-component/SKILL.md +77 -0
- package/README.md +30 -6
- package/bin/check-component.mjs +244 -0
- package/bin/cli.mjs +101 -0
- package/package.json +5 -1
- package/src/editor/component-editor/CodeSnippetEditor.svelte +61 -0
- package/src/editor/component-editor/ToggleEditor.svelte +93 -0
- package/src/editor/component-editor/registry.ts +22 -0
- package/src/editor/overlay/LiveEditorOverlay.svelte +10 -0
- package/src/editor/ui/VariablesTab.svelte +6 -1
- package/src/editor/ui/sections/TokenScaleTable.svelte +28 -7
- package/src/editor/ui/sections/tokenScales.ts +5 -0
- package/src/system/components/CodeSnippet.svelte +118 -0
- package/src/system/components/Toggle.svelte +176 -0
- package/src/system/styles/tokens.generated.css +61 -44
- package/.claude/skills/live-tokens-add-component/SKILL.md +0 -488
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: live-tokens-build-page
|
|
3
|
+
description: Apply the @motion-proto/live-tokens project conventions when building a page: use shipped components from the catalogue, reference theme tokens (never hex/pixel literals), mount routes dynamically, register pageSources, and import site.css per-page. Use when the user asks to build / create / lay out a page, route, hero, marketing page, landing page, dashboard, settings screen, or pricing page; add a route; place / drop / use an existing component on a page; or assemble a screen from the live-tokens catalogue. For component-choice decisions, see live-tokens-pick-component. For authoring a brand-new component, see live-tokens-create-component.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Building pages in a live-tokens project
|
|
7
|
+
|
|
8
|
+
Two rules above all else:
|
|
9
|
+
|
|
10
|
+
1. **Use a shipped component if one fits.** Import from `@motion-proto/live-tokens/components/<Name>.svelte`. See [[live-tokens-pick-component]] for the catalogue and the confusing-pair decisions. Author custom markup only when nothing fits, and then consider [[live-tokens-create-component]] so the new piece is editable too.
|
|
11
|
+
2. **Use theme tokens for every value.** Every color, spacing, radius, font-size, and font-family in page CSS is a `var(--token-*)`. No hex literals. No pixel literals. A change in `/editor` should repaint your page.
|
|
12
|
+
|
|
13
|
+
## Layout
|
|
14
|
+
|
|
15
|
+
Pages sit inside the column grid via `--columns-count`, `--columns-gutter`, `--columns-max-width`. Toggle `ColumnsOverlay` (Cmd+G in dev) to visualise it while placing content.
|
|
16
|
+
|
|
17
|
+
To place children at specific page-column positions, span the parent grid (`grid-column: 1 / -1`), redeclare `repeat(var(--columns-count), 1fr)` with `--columns-gutter`, then refer to children by real page-column numbers. Never fabricate a local `repeat(N, 1fr)` with a hardcoded count: the widths drift from the page grid and the numbers stop matching `ColumnsOverlay`.
|
|
18
|
+
|
|
19
|
+
## Wiring
|
|
20
|
+
|
|
21
|
+
- Mount routes dynamically in `App.svelte` with `$derived.by(() => import(...))`. Static top-level imports evaluate every page module at boot and leak page CSS into editor routes.
|
|
22
|
+
- Register each route in `<LiveEditorOverlay pageSources={...} />` so the "Page Source" button opens the file in VS Code.
|
|
23
|
+
- Import `site.css` from each page's `<script>` block, never from `main.ts` (would leak into editor routes).
|
|
24
|
+
|
|
25
|
+
## Avoid
|
|
26
|
+
|
|
27
|
+
- Hex or pixel literals in page CSS.
|
|
28
|
+
- Hardcoded column counts (`repeat(10, 1fr)`). Use `repeat(var(--columns-count), 1fr)`.
|
|
29
|
+
- Utility classes overriding shipped components. Extend via the `/components` editor instead.
|
|
30
|
+
- Deep imports from `node_modules/@motion-proto/live-tokens/src/...`. Use public entry points only.
|
|
31
|
+
- Mounting `Editor` or `ComponentEditorPage` outside their dedicated routes.
|
|
32
|
+
|
|
33
|
+
## Verify
|
|
34
|
+
|
|
35
|
+
In dev: change a colour in `/editor` and confirm your page repaints (proves token usage). The overlay's "Page Source" button on the new route opens the page in VS Code (proves the `pageSources` entry). `ColumnsOverlay` (Cmd+G) shows content sitting inside `--columns-max-width`.
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: live-tokens-create-component
|
|
3
|
+
description: Author a brand-new editable component for a @motion-proto/live-tokens project when nothing in the shipped catalogue fits. Covers the runtime Svelte file with :global(:root) tokens, the editor Svelte file with allTokens + VariantGroup, the registerComponent() call, naming conventions, state model, public-imports rule, and verification. Use when the user asks to author / create / build / extend a new tokenized component, make an existing Svelte component editable in the live-tokens editor, add a new component to the catalogue, register a custom component with the editor, or build a [Thing] component that does not exist in the shipped set. Not for placing an existing shipped component (Button, Card, etc.) on a page (see live-tokens-build-page); read live-tokens-pick-component first to confirm nothing in the catalogue fits.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Authoring a component for a live-tokens project
|
|
7
|
+
|
|
8
|
+
This skill teaches you how to add a new editable component to a project that consumes `@motion-proto/live-tokens`. The end state: a runtime Svelte file, an editor Svelte file, one `registerComponent()` call, and a `/components` page entry under the **CUSTOM** group with full token editing, linked-block sharing, and persistence.
|
|
9
|
+
|
|
10
|
+
## Worked examples ship inside the package
|
|
11
|
+
|
|
12
|
+
For pattern reference, read any shipped component's source directly from the consumer's `node_modules`:
|
|
13
|
+
|
|
14
|
+
- Runtime files: `node_modules/@motion-proto/live-tokens/src/system/components/<Name>.svelte`.
|
|
15
|
+
- Simplest reads (no state, no linked-block): `Card` (single variant with parts), `Badge` and `Callout` (multi-variant).
|
|
16
|
+
- Multi-state (hover, disabled, focus): `Button`, `Input`.
|
|
17
|
+
- Multi-part (overlay / header / body / footer): `Dialog`.
|
|
18
|
+
- Multi-variant with linked siblings (`canBeLinked` + `groupKey`): `SegmentedControl`, `TabBar`.
|
|
19
|
+
- Composes another shipped component: `CodeSnippet` (renders a `Tooltip` for the copy-confirmation popover).
|
|
20
|
+
- Editor files: `node_modules/@motion-proto/live-tokens/src/editor/component-editor/<Name>Editor.svelte`.
|
|
21
|
+
|
|
22
|
+
**File-location note.** Shipped editors live in `src/editor/component-editor/` because they're library-internal. For *your* component, **co-locate** both files in `src/system/components/` per the recipe below. Read the shipped files for pattern, ignore their location.
|
|
23
|
+
|
|
24
|
+
## 4-step recipe
|
|
25
|
+
|
|
26
|
+
1. **Runtime file** — `src/system/components/MyWidget.svelte`. Declare every editable slot as a CSS custom property inside `:global(:root)`, defaulting to a theme token (never a raw value). The plugin parses `:global(:root)` to seed `component-configs/<id>/default.json`; variables declared anywhere else can't be edited.
|
|
27
|
+
2. **Editor file** — `src/system/components/MyWidgetEditor.svelte`. In a `<script module>` block, declare `const component = 'mywidget'`, build a `states: Record<string, Token[]>` for each VariantGroup, and export the flat union as `allTokens: Token[]`. Components with linked siblings also build a `linkableContexts: Map<string, string>` (see the linked-siblings extension below). In the runtime `<script>` block, mount `ComponentEditorBase` with one `VariantGroup` per variant.
|
|
28
|
+
3. **Register** — in `src/main.ts` before `mount(App, ...)`:
|
|
29
|
+
```ts
|
|
30
|
+
import { registerComponent } from '@motion-proto/live-tokens';
|
|
31
|
+
import MyWidgetEditor, { allTokens as myWidgetTokens } from './system/components/MyWidgetEditor.svelte';
|
|
32
|
+
|
|
33
|
+
registerComponent({
|
|
34
|
+
id: 'mywidget',
|
|
35
|
+
label: 'My Widget',
|
|
36
|
+
icon: 'fas fa-magic',
|
|
37
|
+
sourceFile: 'src/system/components/MyWidget.svelte',
|
|
38
|
+
editorComponent: MyWidgetEditor,
|
|
39
|
+
schema: myWidgetTokens,
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
The schema side-effect happens inside `registerComponent`, so you don't call `registerComponentSchema` separately.
|
|
43
|
+
4. **Tell the picker** — open `.claude/skills/live-tokens-pick-component/SKILL.md` and add your new component to the **Catalogue** line under the family it belongs to (Action / Input / Selection / Containers / Messaging / Display). If it's confusable with an existing component (a second selection control, a competing container), add a row to that family's decision table explaining the use-case it owns. Without this step, the component exists but [[live-tokens-pick-component]] can't recommend it when a user asks "which component should I use?" — the same rule applies whether the component is first-party (update the picker shipped in this package) or consumer-authored (update the local copy at `.claude/skills/live-tokens-pick-component/SKILL.md` that `setup-claude` placed in your project).
|
|
44
|
+
5. **Verify** — open `/components` and run the verification checklist at the bottom of this file.
|
|
45
|
+
|
|
46
|
+
## Token discipline
|
|
47
|
+
|
|
48
|
+
### Naming scheme
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
--<componentId>-<part>[-<state>][-<element>]-<property>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `componentId` — the literal id passed to `registerComponent()`. Lowercase, no dashes, no abbreviations (`segmentedcontrol` not `sc`). The file id matches: `MyWidget.svelte` → id `mywidget`.
|
|
55
|
+
- `part` — sub-region (`bar`, `option`, `track`, `header`, `body`, `footer`, `overlay`, `value`, `label`).
|
|
56
|
+
- `state` (optional) — interaction or component state (`hover`, `disabled`, `selected`, `focus`). **Always before the property.**
|
|
57
|
+
- `element` (optional) — sub-element inside the part (`dot`, `icon`, `label`, `text`).
|
|
58
|
+
- `property` — theme role or CSS property. Always last.
|
|
59
|
+
|
|
60
|
+
### Suffix vocabulary
|
|
61
|
+
|
|
62
|
+
The editor picker is chosen by suffix. There is no per-token override; if a token renders with the wrong picker, rename it to one of these suffixes.
|
|
63
|
+
|
|
64
|
+
**Color and surface**
|
|
65
|
+
|
|
66
|
+
| Suffix | Meaning |
|
|
67
|
+
|-------------|---------------------------------------------------------------|
|
|
68
|
+
| `-surface` | Fill / background color |
|
|
69
|
+
| `-border` | Border color |
|
|
70
|
+
| `-text` | Text color |
|
|
71
|
+
| `-icon` | Icon color |
|
|
72
|
+
| `-label` | Label text color |
|
|
73
|
+
| `-fill` | Inner fill (distinct from outer surface) |
|
|
74
|
+
| `-divider` | Divider / separator color |
|
|
75
|
+
| `-color` | Generic color, when none of the above name the role |
|
|
76
|
+
| `-shadow` | Box-shadow |
|
|
77
|
+
| `-opacity` | Opacity (0–1) |
|
|
78
|
+
| `-blur` | Backdrop or filter blur radius |
|
|
79
|
+
|
|
80
|
+
**Geometry**
|
|
81
|
+
|
|
82
|
+
| Suffix | Meaning |
|
|
83
|
+
|-----------------|---------------------------------------------------------------|
|
|
84
|
+
| `-radius` | Corner radius |
|
|
85
|
+
| `-border-width` | Stroke thickness (used even when CSS uses `outline:`) |
|
|
86
|
+
| `-thickness` | Alternative to `-width` when fallback siblings would collide |
|
|
87
|
+
| `-width` | Width dimension |
|
|
88
|
+
| `-size` | Square / uniform dimension |
|
|
89
|
+
| `-padding` | Internal spacing |
|
|
90
|
+
| `-gap` | Spacing between sibling elements |
|
|
91
|
+
|
|
92
|
+
**Typography**
|
|
93
|
+
|
|
94
|
+
| Suffix | Meaning |
|
|
95
|
+
|--------------------|--------------------------|
|
|
96
|
+
| `-font-family` | Font family reference |
|
|
97
|
+
| `-font-weight` | Font weight reference |
|
|
98
|
+
| `-font-size` | Font size reference |
|
|
99
|
+
| `-line-height` | Line height |
|
|
100
|
+
| `-letter-spacing` | Letter spacing |
|
|
101
|
+
|
|
102
|
+
The authoritative recognised list lives in `bin/check-component.mjs` (`KNOWN_SUFFIXES`). If you need a suffix that isn't listed, either rename to one that is, or open an issue against `@motion-proto/live-tokens` to add it. Don't invent suffixes; the editor falls back to a plain text input and your token won't get a real picker.
|
|
103
|
+
|
|
104
|
+
### Rules that bite
|
|
105
|
+
|
|
106
|
+
- **State before property.** `--mywidget-button-hover-surface` ✓ — `--mywidget-button-surface-hover` ✗ (breaks sibling matching).
|
|
107
|
+
- **Defaults reference theme tokens, never raw values.** `var(--surface-primary)` ✓ — `#6a4ce8` ✗.
|
|
108
|
+
- **No abbreviations.** `bg` → `surface`; `fg` → `text`; component ids are never abbreviated.
|
|
109
|
+
- **Text aliases.** Neutral scale is `--text-primary` / `--text-secondary` / `--text-tertiary` / `--text-muted` / `--text-disabled`. Family-tinted is `--text-primary-color`, `--text-accent`, `--text-success`. There is no `--text-neutral`.
|
|
110
|
+
- **Typography `groupKey` on multi-slot components must include the slot prefix.** `groupKey: 'value-font-family'` and `groupKey: 'label-font-family'` ✓ — bare `groupKey: 'font-family'` silently merges them into one link tree ✗. Single-slot components can use a bare typography `groupKey`; add the slot prefix the moment a second slot appears.
|
|
111
|
+
|
|
112
|
+
### Linked siblings
|
|
113
|
+
|
|
114
|
+
Tokens that share a `groupKey` and declare `canBeLinked: true` form a sibling set with a link toggle in the editor. Linkage is **dev-declared** — you author it when building the component. Users opt out of an existing link per-property; they never add or reshape links. Do not expose UI for users to add siblings or mark properties as linkable.
|
|
115
|
+
|
|
116
|
+
## State model
|
|
117
|
+
|
|
118
|
+
Components *can* have two state axes. Many don't: container and messaging components (Card, Badge, Callout, CollapsibleSection) have only variants, no hover/disabled. Skip the rest of this section for those.
|
|
119
|
+
|
|
120
|
+
When a component does have states, don't mix the two axes:
|
|
121
|
+
|
|
122
|
+
- **Component states** — mutually exclusive top-level fieldsets: `default`, `selected`, `disabled` (names vary by component). One fieldset per component state.
|
|
123
|
+
- **Interaction states** — a select *inside* each component-state fieldset: `default`, `hover`. Add `focus`/`active` later if needed.
|
|
124
|
+
|
|
125
|
+
Rules:
|
|
126
|
+
|
|
127
|
+
- **Disabled is terminal.** A disabled component can't be hovered or focused. The `disabled` fieldset is flat — no interaction selector.
|
|
128
|
+
- **`selected-disabled` is impossible.** Don't author tokens or fieldsets for it.
|
|
129
|
+
- **Parts ≠ states.** Dialog's `overlay | header | body | footer` are *parts* (all present simultaneously), not states. The VariantGroup tab strip defaults its label to "Element" (neutral). If you label tabs anywhere, use **part** for structure and **state** for runtime conditions. Never call a footer a state.
|
|
130
|
+
- **Don't call interaction states "option states" or "selected states"** in the UI. `selected` is a *component* state.
|
|
131
|
+
|
|
132
|
+
Token naming consequence:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
--mywidget-disabled-surface ✓ component-state-level
|
|
136
|
+
--mywidget-option-disabled-surface ✗ implies disabled is an interaction state
|
|
137
|
+
--mywidget-option-hover-surface ✓ default-component-state, hover-interaction
|
|
138
|
+
--mywidget-selected-hover-surface ✓ selected-component-state, hover-interaction
|
|
139
|
+
--mywidget-selected-disabled-text ✗ selected-disabled doesn't exist
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## User-facing copy
|
|
143
|
+
|
|
144
|
+
Strings you author for the editor UI use periods and commas, never em-dashes. Em-dashes read as an AI tell. This applies to `title=` and `description=` on `ComponentEditorBase`, token row labels, info popovers, and any text inside `previewActions` / `canvasToolbarExtras` snippets. Code comments are unaffected.
|
|
145
|
+
|
|
146
|
+
If you add custom chrome inside an editor snippet (rare — `ComponentEditorBase` and `VariantGroup` carry the standard chrome), keep it greyscale (no accent colors) and reference heading sizes via `--ui-font-size-md` / `-lg` / `-2xl` rather than pixel literals.
|
|
147
|
+
|
|
148
|
+
## Public imports only
|
|
149
|
+
|
|
150
|
+
Imports in your runtime, editor, and `main.ts` come from exactly two paths:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { registerComponent, editorState } from '@motion-proto/live-tokens';
|
|
154
|
+
import {
|
|
155
|
+
ComponentEditorBase, VariantGroup,
|
|
156
|
+
computeLinkedBlock, withLinkedDisabled, buildSiblings,
|
|
157
|
+
} from '@motion-proto/live-tokens/component-editor';
|
|
158
|
+
import type { Token } from '@motion-proto/live-tokens/component-editor';
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
That covers everything the worked examples use. Additional primitives (`LinkedBlock`, `TypeEditor`, `TokenLayout`, `buildTypeGroupTokens`, more types) are exported from the same paths for advanced cases.
|
|
162
|
+
|
|
163
|
+
**Never deep-import `node_modules/@motion-proto/live-tokens/src/...`.** Reading those files for pattern reference is fine; importing them at runtime is not. If you need something not exported, file an issue rather than reaching in.
|
|
164
|
+
|
|
165
|
+
## Worked example: shipped Toggle, end-to-end
|
|
166
|
+
|
|
167
|
+
Toggle ships in the package and exercises every rule above. For your own component, copy this pattern and substitute your id; just don't reuse `toggle` itself (registrations against a built-in id win with a console warning, but the right call is a unique id).
|
|
168
|
+
|
|
169
|
+
### Runtime: `src/system/components/Toggle.svelte`
|
|
170
|
+
|
|
171
|
+
```svelte
|
|
172
|
+
<script lang="ts">
|
|
173
|
+
interface Props {
|
|
174
|
+
checked?: boolean;
|
|
175
|
+
disabled?: boolean;
|
|
176
|
+
label?: string;
|
|
177
|
+
/** Editor preview hook. Paints hover tokens without a real pointer. */
|
|
178
|
+
class?: string;
|
|
179
|
+
onchange?: (checked: boolean) => void;
|
|
180
|
+
}
|
|
181
|
+
let {
|
|
182
|
+
checked = false, disabled = false, label = '',
|
|
183
|
+
class: className = '', onchange,
|
|
184
|
+
}: Props = $props();
|
|
185
|
+
function toggle() {
|
|
186
|
+
if (disabled) return;
|
|
187
|
+
onchange?.(!checked);
|
|
188
|
+
}
|
|
189
|
+
</script>
|
|
190
|
+
|
|
191
|
+
<button
|
|
192
|
+
type="button" role="switch" aria-checked={checked}
|
|
193
|
+
class="toggle {className}" class:on={checked}
|
|
194
|
+
{disabled} onclick={toggle}
|
|
195
|
+
>
|
|
196
|
+
<span class="track"><span class="thumb"></span></span>
|
|
197
|
+
{#if label}<span class="label">{label}</span>{/if}
|
|
198
|
+
</button>
|
|
199
|
+
|
|
200
|
+
<style>
|
|
201
|
+
:global(:root) {
|
|
202
|
+
/* Default (off resting). Carries geometry + label typography for every state. */
|
|
203
|
+
--toggle-track-surface: var(--surface-neutral);
|
|
204
|
+
--toggle-track-border: var(--border-neutral);
|
|
205
|
+
--toggle-track-border-width: var(--border-width-1);
|
|
206
|
+
--toggle-track-radius: var(--radius-full);
|
|
207
|
+
--toggle-track-width: var(--space-32);
|
|
208
|
+
--toggle-track-thickness: var(--space-16);
|
|
209
|
+
--toggle-thumb-surface: var(--surface-neutral-highest);
|
|
210
|
+
--toggle-thumb-border: var(--border-neutral-strong);
|
|
211
|
+
--toggle-thumb-size: var(--space-12);
|
|
212
|
+
--toggle-label-text: var(--text-primary);
|
|
213
|
+
--toggle-label-font-family: var(--font-sans);
|
|
214
|
+
--toggle-label-font-size: var(--font-size-sm);
|
|
215
|
+
--toggle-label-font-weight: var(--font-weight-normal);
|
|
216
|
+
--toggle-gap: var(--space-8);
|
|
217
|
+
|
|
218
|
+
/* Hover (default + hover interaction). */
|
|
219
|
+
--toggle-hover-track-surface: var(--surface-neutral-high);
|
|
220
|
+
--toggle-hover-thumb-surface: var(--text-primary);
|
|
221
|
+
|
|
222
|
+
/* On (component state). */
|
|
223
|
+
--toggle-on-track-surface: var(--surface-brand-high);
|
|
224
|
+
--toggle-on-track-border: var(--border-brand);
|
|
225
|
+
--toggle-on-thumb-surface: var(--text-primary);
|
|
226
|
+
--toggle-on-thumb-border: var(--border-brand-strong);
|
|
227
|
+
|
|
228
|
+
/* On + hover. */
|
|
229
|
+
--toggle-on-hover-track-surface: var(--surface-brand-higher);
|
|
230
|
+
--toggle-on-hover-thumb-surface: var(--text-primary);
|
|
231
|
+
|
|
232
|
+
/* Disabled (terminal, applies regardless of on/off). */
|
|
233
|
+
--toggle-disabled-track-surface: var(--surface-neutral-lower);
|
|
234
|
+
--toggle-disabled-thumb-surface: var(--surface-neutral);
|
|
235
|
+
--toggle-disabled-label-text: var(--text-disabled);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.toggle { display: inline-flex; align-items: center; gap: var(--toggle-gap); }
|
|
239
|
+
.track {
|
|
240
|
+
width: var(--toggle-track-width); height: var(--toggle-track-thickness);
|
|
241
|
+
background: var(--toggle-track-surface);
|
|
242
|
+
border: var(--toggle-track-border-width) solid var(--toggle-track-border);
|
|
243
|
+
border-radius: var(--toggle-track-radius);
|
|
244
|
+
}
|
|
245
|
+
.thumb {
|
|
246
|
+
width: var(--toggle-thumb-size); height: var(--toggle-thumb-size);
|
|
247
|
+
background: var(--toggle-thumb-surface);
|
|
248
|
+
border: var(--toggle-track-border-width) solid var(--toggle-thumb-border);
|
|
249
|
+
}
|
|
250
|
+
.label {
|
|
251
|
+
color: var(--toggle-label-text);
|
|
252
|
+
font-family: var(--toggle-label-font-family);
|
|
253
|
+
font-size: var(--toggle-label-font-size);
|
|
254
|
+
font-weight: var(--toggle-label-font-weight);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.toggle:hover:not(:disabled) .track,
|
|
258
|
+
.toggle.force-hover:not(:disabled) .track { background: var(--toggle-hover-track-surface); }
|
|
259
|
+
.toggle:hover:not(:disabled) .thumb,
|
|
260
|
+
.toggle.force-hover:not(:disabled) .thumb { background: var(--toggle-hover-thumb-surface); }
|
|
261
|
+
|
|
262
|
+
.toggle.on .track {
|
|
263
|
+
background: var(--toggle-on-track-surface);
|
|
264
|
+
border-color: var(--toggle-on-track-border);
|
|
265
|
+
}
|
|
266
|
+
.toggle.on .thumb {
|
|
267
|
+
background: var(--toggle-on-thumb-surface);
|
|
268
|
+
border-color: var(--toggle-on-thumb-border);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.toggle.on:hover:not(:disabled) .track,
|
|
272
|
+
.toggle.on.force-hover:not(:disabled) .track { background: var(--toggle-on-hover-track-surface); }
|
|
273
|
+
.toggle.on:hover:not(:disabled) .thumb,
|
|
274
|
+
.toggle.on.force-hover:not(:disabled) .thumb { background: var(--toggle-on-hover-thumb-surface); }
|
|
275
|
+
|
|
276
|
+
.toggle:disabled .track { background: var(--toggle-disabled-track-surface); }
|
|
277
|
+
.toggle:disabled .thumb { background: var(--toggle-disabled-thumb-surface); }
|
|
278
|
+
.toggle:disabled .label { color: var(--toggle-disabled-label-text); }
|
|
279
|
+
</style>
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
What to notice:
|
|
283
|
+
|
|
284
|
+
- The `:global(:root)` block declares every editable variable. Variables in scoped selectors don't get edited; the plugin only parses `:global(:root)`.
|
|
285
|
+
- Every default references a theme token; no raw values.
|
|
286
|
+
- Component states (`on`, `disabled`) name themselves in the token: `--toggle-on-*`, `--toggle-disabled-*`.
|
|
287
|
+
- Interaction states layer on top: `--toggle-hover-*` for default+hover, `--toggle-on-hover-*` for on+hover.
|
|
288
|
+
- Disabled is terminal: no `--toggle-disabled-hover-*`, no `--toggle-on-disabled-*`.
|
|
289
|
+
- The `force-hover` class pairs with the editor's preview hook so hover tokens paint without a real pointer. Each `:hover` selector has a matching `.force-hover` sibling.
|
|
290
|
+
|
|
291
|
+
### Editor: `src/system/components/ToggleEditor.svelte`
|
|
292
|
+
|
|
293
|
+
```svelte
|
|
294
|
+
<script module lang="ts">
|
|
295
|
+
import type { Token } from '@motion-proto/live-tokens/component-editor';
|
|
296
|
+
export const component = 'toggle';
|
|
297
|
+
|
|
298
|
+
const states: Record<string, Token[]> = {
|
|
299
|
+
default: [
|
|
300
|
+
{ label: 'track surface', variable: '--toggle-track-surface' },
|
|
301
|
+
{ label: 'track border', variable: '--toggle-track-border' },
|
|
302
|
+
{ label: 'track border width', variable: '--toggle-track-border-width' },
|
|
303
|
+
{ label: 'track radius', variable: '--toggle-track-radius' },
|
|
304
|
+
{ label: 'track width', variable: '--toggle-track-width' },
|
|
305
|
+
{ label: 'track thickness', variable: '--toggle-track-thickness' },
|
|
306
|
+
{ label: 'thumb surface', variable: '--toggle-thumb-surface' },
|
|
307
|
+
{ label: 'thumb border', variable: '--toggle-thumb-border' },
|
|
308
|
+
{ label: 'thumb size', variable: '--toggle-thumb-size' },
|
|
309
|
+
{ label: 'label text', variable: '--toggle-label-text' },
|
|
310
|
+
{ label: 'label font family', variable: '--toggle-label-font-family' },
|
|
311
|
+
{ label: 'label font size', variable: '--toggle-label-font-size' },
|
|
312
|
+
{ label: 'label font weight', variable: '--toggle-label-font-weight' },
|
|
313
|
+
{ label: 'label gap', variable: '--toggle-gap' },
|
|
314
|
+
],
|
|
315
|
+
hover: [
|
|
316
|
+
{ label: 'track surface', variable: '--toggle-hover-track-surface' },
|
|
317
|
+
{ label: 'thumb surface', variable: '--toggle-hover-thumb-surface' },
|
|
318
|
+
],
|
|
319
|
+
on: [
|
|
320
|
+
{ label: 'track surface', variable: '--toggle-on-track-surface' },
|
|
321
|
+
{ label: 'track border', variable: '--toggle-on-track-border' },
|
|
322
|
+
{ label: 'thumb surface', variable: '--toggle-on-thumb-surface' },
|
|
323
|
+
{ label: 'thumb border', variable: '--toggle-on-thumb-border' },
|
|
324
|
+
],
|
|
325
|
+
'on hover': [
|
|
326
|
+
{ label: 'track surface', variable: '--toggle-on-hover-track-surface' },
|
|
327
|
+
{ label: 'thumb surface', variable: '--toggle-on-hover-thumb-surface' },
|
|
328
|
+
],
|
|
329
|
+
disabled: [
|
|
330
|
+
{ label: 'track surface', variable: '--toggle-disabled-track-surface' },
|
|
331
|
+
{ label: 'thumb surface', variable: '--toggle-disabled-thumb-surface' },
|
|
332
|
+
{ label: 'label text', variable: '--toggle-disabled-label-text' },
|
|
333
|
+
],
|
|
334
|
+
};
|
|
335
|
+
export const allTokens: Token[] = Object.values(states).flat();
|
|
336
|
+
</script>
|
|
337
|
+
|
|
338
|
+
<script lang="ts">
|
|
339
|
+
import Toggle from './Toggle.svelte';
|
|
340
|
+
import {
|
|
341
|
+
VariantGroup, ComponentEditorBase,
|
|
342
|
+
} from '@motion-proto/live-tokens/component-editor';
|
|
343
|
+
|
|
344
|
+
function previewProps(state: string) {
|
|
345
|
+
return {
|
|
346
|
+
checked: state === 'on' || state === 'on hover',
|
|
347
|
+
disabled: state === 'disabled',
|
|
348
|
+
forceClass: state === 'hover' || state === 'on hover' ? 'force-hover' : '',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
</script>
|
|
352
|
+
|
|
353
|
+
<ComponentEditorBase
|
|
354
|
+
{component}
|
|
355
|
+
title="Toggle"
|
|
356
|
+
description="On/off switch with sliding thumb."
|
|
357
|
+
tokens={allTokens}
|
|
358
|
+
>
|
|
359
|
+
<VariantGroup name="toggle" title="Toggle" {states} {component}>
|
|
360
|
+
{#snippet children({ activeState })}
|
|
361
|
+
{@const p = previewProps(activeState)}
|
|
362
|
+
<Toggle checked={p.checked} disabled={p.disabled} class={p.forceClass} label="Enable feature" />
|
|
363
|
+
{/snippet}
|
|
364
|
+
</VariantGroup>
|
|
365
|
+
</ComponentEditorBase>
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
What to notice:
|
|
369
|
+
|
|
370
|
+
- `component` is the string id; must match `registerComponent({ id })` exactly.
|
|
371
|
+
- `states` keys become the VariantGroup tab labels the user sees. Multi-word keys (`'on hover'`) need quoting.
|
|
372
|
+
- `allTokens` is a flat union of every state's tokens. The editor store needs it for reset-to-default and sibling resolution.
|
|
373
|
+
- `previewProps` translates the active editor tab into runtime props (`checked`, `disabled`, `force-hover` class).
|
|
374
|
+
- No `groupKey`, no `canBeLinked`: Toggle has no linked siblings. For components that share base properties across variants, see the linked-siblings extension below.
|
|
375
|
+
|
|
376
|
+
### Register: `src/main.ts`
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
import { registerComponent } from '@motion-proto/live-tokens';
|
|
380
|
+
import ToggleEditor, { allTokens as toggleTokens } from './system/components/ToggleEditor.svelte';
|
|
381
|
+
|
|
382
|
+
registerComponent({
|
|
383
|
+
id: 'mytoggle', // unique id; don't reuse 'toggle'
|
|
384
|
+
label: 'My Toggle',
|
|
385
|
+
icon: 'fas fa-toggle-on',
|
|
386
|
+
sourceFile: 'src/system/components/Toggle.svelte',
|
|
387
|
+
editorComponent: ToggleEditor,
|
|
388
|
+
schema: toggleTokens,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// then mount(App, ...)
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
If you do this with `id: 'toggle'`, the consumer's component wins over the built-in (with a console warning). The collision rule protects you, but for a fresh component pick an id that doesn't collide.
|
|
395
|
+
|
|
396
|
+
## Extension: linked siblings
|
|
397
|
+
|
|
398
|
+
Toggle's tokens are flat per state. Most multi-variant components (Badge, Card, SegmentedControl) share base properties across variants and surface that equality via a *linked block*: one edit propagates to every variant, while per-variant properties stay independent. Five additions to the Toggle pattern; see `BadgeEditor.svelte` in `node_modules` for the full file.
|
|
399
|
+
|
|
400
|
+
1. **Mark linkable tokens** with `canBeLinked: true` + a `groupKey`. Peers sharing a `groupKey` form a link set across variants.
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
function variantBaseTokens(v: Variant): Token[] {
|
|
404
|
+
return [
|
|
405
|
+
{ label: 'padding', canBeLinked: true, groupKey: 'padding', variable: `--badge-${v}-padding` },
|
|
406
|
+
{ label: 'corner radius', canBeLinked: true, groupKey: 'radius', variable: `--badge-${v}-radius` },
|
|
407
|
+
];
|
|
408
|
+
}
|
|
409
|
+
// Colors omit canBeLinked. Per-variant by design.
|
|
410
|
+
function variantColorTokens(v: Variant): Token[] {
|
|
411
|
+
return [
|
|
412
|
+
{ label: 'surface color', groupKey: 'surface', variable: `--badge-${v}-surface` },
|
|
413
|
+
{ label: 'text color', groupKey: 'text', variable: `--badge-${v}-text` },
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
2. **Build a `linkableContexts: Map<variable, contextLabel>`** in `<script module>`. The label (e.g. `"success base"`) is how the LinkageChart row identifies this variable. Plain literal Map, no helper needed.
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
const linkableContexts = new Map<string, string>(
|
|
422
|
+
variants.flatMap((v) =>
|
|
423
|
+
variantBaseTokens(v)
|
|
424
|
+
.filter((t) => t.canBeLinked)
|
|
425
|
+
.map((t) => [t.variable, `${v} base`] as [string, string]),
|
|
426
|
+
),
|
|
427
|
+
);
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
3. **Compute `linked` and mask currently-linked rows** out of per-state lists, so they render once inside the LinkedBlock instead of twice.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
import { editorState } from '@motion-proto/live-tokens';
|
|
434
|
+
import { computeLinkedBlock, withLinkedDisabled, buildSiblings }
|
|
435
|
+
from '@motion-proto/live-tokens/component-editor';
|
|
436
|
+
|
|
437
|
+
let linked = $derived(computeLinkedBlock(component, linkableContexts, allTokens, $editorState));
|
|
438
|
+
let visibleVariantStates = $derived((v: Variant) => Object.fromEntries(
|
|
439
|
+
Object.entries(variantStates(v)).map(([name, list]) => [name, withLinkedDisabled(list, linked.varSet)]),
|
|
440
|
+
));
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
4. **Pass `{linked}` to `ComponentEditorBase`** so the LinkedBlock renders above the variant groups.
|
|
444
|
+
|
|
445
|
+
5. **Multi-variant editors iterate VariantGroups** with `buildSiblings` so cross-variant link rows resolve to their peers.
|
|
446
|
+
|
|
447
|
+
```svelte
|
|
448
|
+
<ComponentEditorBase {component} title="Badge" tokens={allTokens} {linked} variants={variantOptions}>
|
|
449
|
+
{#each variants as v}
|
|
450
|
+
<VariantGroup
|
|
451
|
+
name={v}
|
|
452
|
+
title={v}
|
|
453
|
+
states={visibleVariantStates(v)}
|
|
454
|
+
{component}
|
|
455
|
+
siblings={buildSiblings(variants, v, variantStates)}
|
|
456
|
+
>
|
|
457
|
+
...preview snippet
|
|
458
|
+
</VariantGroup>
|
|
459
|
+
{/each}
|
|
460
|
+
</ComponentEditorBase>
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Single-variant components with multi-state linked tokens still set `canBeLinked` + `linkableContexts`, but skip `buildSiblings` and the `{#each}` loop. Components with no linked tokens (Toggle, SectionDivider) skip all five steps — `ComponentEditorBase` renders fine without a `{linked}` prop.
|
|
464
|
+
|
|
465
|
+
## Verification checklist
|
|
466
|
+
|
|
467
|
+
After saving, run the static validator first:
|
|
468
|
+
|
|
469
|
+
```bash
|
|
470
|
+
npx live-tokens check-component <id>
|
|
471
|
+
# or: npx @motion-proto/live-tokens check-component <id>
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
It enforces the file layout, the `:global(:root)` block, token-suffix vocabulary, state-before-property rule, theme-token defaults (no raw colour literals), public-imports rule, and the `registerComponent({ id })` call. Exit code 0 means the static contract is met.
|
|
475
|
+
|
|
476
|
+
Then navigate to `/components` and confirm the runtime behaviours the static check can't see:
|
|
477
|
+
|
|
478
|
+
- [ ] The new component appears in the nav rail under the **CUSTOM** group (system entries above, custom below the labeled divider).
|
|
479
|
+
- [ ] Token rows render. Color pickers, radius selectors, font selectors all work.
|
|
480
|
+
- [ ] Linked-block (if your component has linked siblings): shared rows appear with the link toggle. Changing the linked value broadcasts across every variant.
|
|
481
|
+
- [ ] First save creates `component-configs/<id>/default.json`. Subsequent saves write `_active.json` plus any named files.
|
|
482
|
+
- [ ] Reset returns each variable to its `:global(:root)` default.
|
|
483
|
+
- [ ] Boot validation is clean (no warnings about the component being missing from the server scan, or about disk-vs-registry drift).
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: live-tokens-pick-component
|
|
3
|
+
description: Recommend which shipped @motion-proto/live-tokens component fits a UX need, with decision trees for the confusable pairs (SegmentedControl vs TabBar vs RadioButton vs MenuSelect; Card vs CollapsibleSection vs Dialog; Callout vs Notification vs Tooltip; Badge vs CornerBadge; Toggle vs SegmentedControl vs RadioButton). Use when the user asks which / what component to use, should I use X or Y, what is the difference between two components, how do I show / let the user / capture / display some UX outcome, or starts to author a custom component before checking the catalogue. Read this before reaching for live-tokens-create-component. Not for actually placing the chosen component on a page (see live-tokens-build-page).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Picking the right live-tokens component
|
|
7
|
+
|
|
8
|
+
This skill helps you choose between shipped components when several could plausibly fit. The catalogue is small; the hard part is semantic intent. A `RadioButton` set and a `SegmentedControl` can render identical-looking UIs but communicate different things.
|
|
9
|
+
|
|
10
|
+
For composing a page once you've picked components, see [[live-tokens-build-page]]. For authoring a brand-new component when nothing fits, see [[live-tokens-create-component]] (but read this skill first to confirm nothing in the catalogue fits).
|
|
11
|
+
|
|
12
|
+
## Catalogue
|
|
13
|
+
|
|
14
|
+
Action: `Button`. Input: `Input`. Selection: `SegmentedControl`, `TabBar`, `RadioButton`, `MenuSelect`, `Toggle`. Containers: `Card`, `CollapsibleSection`, `Dialog`. Messaging: `Callout`, `Notification`, `Tooltip`, `Badge`, `CornerBadge`. Display: `Table`, `Image`, `ImageLightbox`, `ProgressBar`, `SectionDivider`, `SideNavigation`, `CodeSnippet`.
|
|
15
|
+
|
|
16
|
+
`CodeSnippet` is for a single-line command or value the user is meant to copy and paste back into a terminal (install commands, generated keys, ids). Click-to-copy with a brief "Copied" popover. Use it whenever your page asks the reader to *run* something, rather than just *read* it.
|
|
17
|
+
|
|
18
|
+
## Single-selection family: SegmentedControl vs TabBar vs RadioButton vs MenuSelect
|
|
19
|
+
|
|
20
|
+
All four pick one option from a set. The right one depends on **option count**, **whether the selection changes what's rendered below**, and **how much visual weight** you want.
|
|
21
|
+
|
|
22
|
+
| Component | Best for | Visual weight | Option count |
|
|
23
|
+
|-------------------|-------------------------------------------------------------------------|------------------|--------------|
|
|
24
|
+
| `SegmentedControl`| Inline switch between alternative *views of the same data* | Compact pill | 2–4 |
|
|
25
|
+
| `TabBar` | Switching between *tab panels* (content area swaps below) | Page-section | 2–7 |
|
|
26
|
+
| `RadioButton` | Form-style selection where the user reviews all options as text | Form-row | Any |
|
|
27
|
+
| `MenuSelect` | Hide the option set behind a dropdown to save vertical/horizontal space | Compact dropdown | Any |
|
|
28
|
+
|
|
29
|
+
- `TabBar` implies "this changes the page"; `SegmentedControl` implies "this is one knob among others."
|
|
30
|
+
- Use `RadioButton` when labels deserve room to breathe and the user is committing to a larger form.
|
|
31
|
+
- Use `MenuSelect` when options would overflow horizontally or there are too many to display at once.
|
|
32
|
+
- **Don't pick `SegmentedControl` when option labels are long enough to wrap.** It loses its compactness; use `RadioButton` rows instead.
|
|
33
|
+
|
|
34
|
+
## Container family: Card vs CollapsibleSection vs Dialog
|
|
35
|
+
|
|
36
|
+
| Component | Modality | Use for |
|
|
37
|
+
|-----------------------|---------------------|-------------------------------------------------------------|
|
|
38
|
+
| `Card` | Inline, always open | Default container for grouped content |
|
|
39
|
+
| `CollapsibleSection` | Inline, toggleable | Progressive disclosure inside a longer page |
|
|
40
|
+
| `Dialog` | Modal, blocks page | Confirmations, focused tasks the page can't continue around |
|
|
41
|
+
|
|
42
|
+
- Default to `Card`. It's the workhorse.
|
|
43
|
+
- Reach for `CollapsibleSection` only when the content is *legitimately secondary* (advanced users open it; most skip). Don't use collapse as a styling choice when the content matters.
|
|
44
|
+
- **Don't use `Dialog` for routine forms.** Reach for it only when the page cannot meaningfully continue until the user decides (destructive confirmations, payment, sign-in). Routine forms go inline in a `Card`.
|
|
45
|
+
|
|
46
|
+
## Messaging family: Callout vs Notification vs Tooltip vs Badge
|
|
47
|
+
|
|
48
|
+
| Component | Scope | Triggered by | Dismissable | Use for |
|
|
49
|
+
|-----------------|----------------|-----------------|-------------|------------------------------------------------------|
|
|
50
|
+
| `Callout` | Section-inline | Always present | No | "Heads up about this section" |
|
|
51
|
+
| `Notification` | System-level | Event / save | Yes | "Your changes were saved" |
|
|
52
|
+
| `Tooltip` | Element-inline | Hover / focus | Auto | Definition or hint anchored to an element |
|
|
53
|
+
| `Badge` | Element-inline | Always present | No | Status pill ("Beta", "New", "v2") |
|
|
54
|
+
| `CornerBadge` | Element-corner | Always present | No | Position-anchored marker (count, status dot) |
|
|
55
|
+
|
|
56
|
+
- `Callout` is *content*. Part of the section, written into the markup, says something important about what surrounds it. Variants (`info`, `success`, `warning`, `danger`) set the tone.
|
|
57
|
+
- `Notification` is *feedback*. Appears in response to an action, then dismisses. **Don't use `Notification` for static content;** persistent messages belong in a `Callout`.
|
|
58
|
+
- `Tooltip` is for *what an element means*. **Don't use `Tooltip` as the primary location of important content;** it auto-dismisses and isn't accessible for must-read content.
|
|
59
|
+
- `Badge` and `CornerBadge` differ only in positioning. `CornerBadge` lives at a `top-right` / `bottom-left` anchor on a parent (notification counts, "NEW" stickers).
|
|
60
|
+
|
|
61
|
+
## Toggle vs SegmentedControl vs RadioButton (for on/off)
|
|
62
|
+
|
|
63
|
+
All three can express a binary choice. The right one depends on what the choice *is*.
|
|
64
|
+
|
|
65
|
+
| Component | Best for |
|
|
66
|
+
|--------------------|-----------------------------------------------------------------------------------------------------------------------|
|
|
67
|
+
| `Toggle` | A *setting* that's either on or off (notifications on/off, dark mode). The label names the setting; the switch shows the state. |
|
|
68
|
+
| `SegmentedControl` | A *choice between two named alternatives* (Light / Dark, List / Grid). Both labels are visible at once. |
|
|
69
|
+
| `RadioButton` pair | A *form-style choice* where the user reviews both labels before committing (Yes / No questions, opt-in selections). |
|
|
70
|
+
|
|
71
|
+
- If the off and on states share a name (the feature itself), it's `Toggle`. "Email notifications" has no "off" label because the switch position is the state.
|
|
72
|
+
- If the two states have different names you want users to compare, it's `SegmentedControl`.
|
|
73
|
+
- `Toggle` flips immediately; `RadioButton` pair is for forms where the choice is part of a larger submission.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
If nothing in the catalogue fits (a `Slider`, a `DatePicker`, a `Stepper`, a custom widget), author it via [[live-tokens-create-component]]. **Don't reach for a custom component before checking the catalogue;** a custom component is a maintenance commitment.
|