@rokkit/cli 1.1.13 → 1.1.15

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.
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Derive a safe localStorage key from an npm package name.
3
+ * Strips the scope, keeps case, maps unsafe chars to '-'. Returns '' when there
4
+ * is no usable name — the caller then omits `storageKey` rather than baking in a
5
+ * hardcoded default (the consumer config / library fallback decides instead).
6
+ * @param {string} [name]
7
+ * @returns {string}
8
+ */
9
+ export function storageKeyFromName(name?: string): string;
1
10
  /**
2
11
  * Serialize a rokkit config object to a JS module string with a header comment.
3
12
  * @param {Record<string, unknown>} config
@@ -31,7 +40,8 @@ export function generateChartConfig({ chartColors, chartShades }: {
31
40
  /**
32
41
  * Build the Zen-Sumi OKLCH starter (ink-on-paper, dual-palette dark mode).
33
42
  * @param {{ themes?: string[], defaultTheme?: string, switcher?: string,
34
- * includeChart?: boolean, chartColors?: string, chartShades?: string }} [opts]
43
+ * includeChart?: boolean, chartColors?: string, chartShades?: string,
44
+ * storageKey?: string }} [opts]
35
45
  * @returns {Record<string, unknown>}
36
46
  */
37
47
  export function generateZenSumiConfig(opts?: {
@@ -41,6 +51,7 @@ export function generateZenSumiConfig(opts?: {
41
51
  includeChart?: boolean;
42
52
  chartColors?: string;
43
53
  chartShades?: string;
54
+ storageKey?: string;
44
55
  }): Record<string, unknown>;
45
56
  /**
46
57
  * Build a Rokkit config object from the user's init choices.
@@ -48,10 +59,11 @@ export function generateZenSumiConfig(opts?: {
48
59
  * optional `icons` and `chart` sections.
49
60
  * @param {{ palette: string, customColors?: Record<string, string>, icons?: string,
50
61
  * iconPath?: string, iconStyle?: string, themes: string[], defaultTheme?: string,
51
- * switcher: string, includeChart?: boolean, chartColors?: string, chartShades?: string }} opts
62
+ * switcher: string, includeChart?: boolean, chartColors?: string, chartShades?: string,
63
+ * storageKey?: string }} opts
52
64
  * @returns {Record<string, unknown>}
53
65
  */
54
- export function generateConfig({ palette, customColors, icons, iconPath, iconStyle, themes, defaultTheme, switcher, includeChart, chartColors, chartShades }: {
66
+ export function generateConfig({ palette, customColors, icons, iconPath, iconStyle, themes, defaultTheme, switcher, includeChart, chartColors, chartShades, storageKey }: {
55
67
  palette: string;
56
68
  customColors?: Record<string, string>;
57
69
  icons?: string;
@@ -63,6 +75,7 @@ export function generateConfig({ palette, customColors, icons, iconPath, iconSty
63
75
  includeChart?: boolean;
64
76
  chartColors?: string;
65
77
  chartShades?: string;
78
+ storageKey?: string;
66
79
  }): Record<string, unknown>;
67
80
  /**
68
81
  * Generate the content for uno.config.js.
@@ -75,22 +88,6 @@ export function generateUnoConfig(): string;
75
88
  * @returns {string[]}
76
89
  */
77
90
  export function generateAppCssImports(themes: string[]): string[];
78
- /**
79
- * Generate the flash-prevention script for app.html.
80
- *
81
- * Themes flip on `[data-mode="dark"]` only — there is no `@media
82
- * (prefers-color-scheme)` fallback — so every switcher needs a script that
83
- * resolves the mode pre-paint (mirrors `@rokkit/unocss`'s `themeHook`):
84
- * - `system` tracks the OS preference directly (no persisted toggle).
85
- * - `manual` / `full` read the persisted mode and resolve a stored-or-default
86
- * `'system'`/`'auto'` against the OS preference (default: follow the OS).
87
- *
88
- * @param {string} switcher — 'system' | 'manual' | 'full'
89
- * @param {string} [storageKey='rokkit-theme']
90
- * @param {string} [defaultStyle='rokkit']
91
- * @returns {string} the `<script>` tag to inject after `<body>`
92
- */
93
- export function generateInitScript(switcher: string, storageKey?: string, defaultStyle?: string): string;
94
91
  /**
95
92
  * Interactive init command — prompts the user, writes config files.
96
93
  * @param {Record<string, unknown>} [_opts]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/cli",
3
- "version": "1.1.13",
3
+ "version": "1.1.15",
4
4
  "description": "Command line utilities for icon bundling and conversion.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@mdi/svg": "^7.4.47",
30
+ "@rokkit/unocss": "1.1.15",
30
31
  "sade": "^1.8.1",
31
32
  "@iconify/tools": "5.0.3",
32
33
  "@iconify/types": "^2.0.0",
@@ -0,0 +1,256 @@
1
+ ---
2
+ name: command-system-rokkit
3
+ description: Use when adding keyboard shortcuts, a command palette, or app-level commands to a Rokkit (Svelte 5) app — the commands registry (@rokkit/states), the shortcuts action (@rokkit/actions), the CommandPalette component (@rokkit/ui), the 'mod+k' shortcut grammar, command scope/lifecycle, conflict detection, and i18n.
4
+ ---
5
+
6
+ # Rokkit Command System
7
+
8
+ Three packages, one coherent system — commands live in a registry, shortcuts fire them from the keyboard,
9
+ the palette lets users search and run them by name.
10
+
11
+ ```
12
+ @rokkit/states → commands registry (register, execute, resolve, commands.all)
13
+ @rokkit/actions → shortcuts action (use:shortcuts={commands})
14
+ @rokkit/ui → CommandPalette component (<CommandPalette bind:open />)
15
+ ```
16
+
17
+ Parallel to the theme system: `vibe` (state) + `themable` (action) + `ThemeSwitcherToggle` (component).
18
+ Commands are the same shape — registry singleton + action wiring + a ready-made UI.
19
+
20
+ ---
21
+
22
+ ## The three pieces
23
+
24
+ | Package | Export | Role |
25
+ |---------|--------|------|
26
+ | `@rokkit/states` | `commands` | Singleton registry — register, resolve, execute |
27
+ | `@rokkit/actions` | `shortcuts` | Svelte action — intercepts `keydown`, delegates to `commands` |
28
+ | `@rokkit/ui` | `CommandPalette` | Searchable dialog — reads `commands.all`, opens on `mod+k` |
29
+
30
+ ---
31
+
32
+ ## Defining commands
33
+
34
+ A `Command` object has one required field (`run`) plus metadata:
35
+
36
+ ```ts
37
+ type Command = {
38
+ id: string // unique — re-registering the same id replaces and warns
39
+ label: string // display text in the palette (consumer-localized)
40
+ run: () => void // executed on shortcut match or palette selection
41
+ shortcut?: string // e.g. 'mod+k', 'mod+shift+l', '?'
42
+ group?: string // palette category header
43
+ global?: boolean // fire even while a text input is focused (default false)
44
+ enabled?: () => boolean // dynamic gate — command is skipped when returns false
45
+ keywords?: string[] // extra palette search terms
46
+ }
47
+ ```
48
+
49
+ Register a batch with `registerMany` (returns a single unregister callback):
50
+
51
+ ```js
52
+ import { commands } from '@rokkit/states'
53
+
54
+ const unregister = commands.registerMany([
55
+ {
56
+ id: 'palette.open',
57
+ label: 'Open command palette',
58
+ shortcut: 'mod+k',
59
+ global: true, // fires while typing — required for Cmd/Ctrl+K
60
+ group: 'general',
61
+ run: () => { paletteOpen = true }
62
+ },
63
+ {
64
+ id: 'theme.toggle',
65
+ label: 'Toggle light / dark',
66
+ shortcut: 'mod+shift+l',
67
+ group: 'theme',
68
+ run: () => { vibe.mode = vibe.mode === 'dark' ? 'light' : 'dark' }
69
+ },
70
+ {
71
+ id: 'conversation.new',
72
+ label: 'New conversation',
73
+ shortcut: 'mod+shift+n',
74
+ group: 'general',
75
+ run: startNewConversation
76
+ }
77
+ ])
78
+ ```
79
+
80
+ Register one command with `register` (also returns an unregister callback):
81
+
82
+ ```js
83
+ const unregister = commands.register({
84
+ id: 'search.focus',
85
+ label: 'Focus search',
86
+ shortcut: '/',
87
+ run: () => searchInput?.focus()
88
+ })
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Wiring it up
94
+
95
+ Wire the root element once (e.g. `+layout.svelte`). Register commands in `onMount`
96
+ and return the unregister fn — Svelte calls it on destroy to clean up:
97
+
98
+ ```svelte
99
+ <script>
100
+ import { onMount } from 'svelte'
101
+ import { commands, vibe } from '@rokkit/states'
102
+ import { shortcuts } from '@rokkit/actions'
103
+ import { CommandPalette } from '@rokkit/ui'
104
+
105
+ let paletteOpen = $state(false)
106
+
107
+ onMount(() => {
108
+ return commands.registerMany([
109
+ {
110
+ id: 'palette.open',
111
+ label: 'Open command palette',
112
+ shortcut: 'mod+k',
113
+ global: true,
114
+ group: 'general',
115
+ run: () => { paletteOpen = true }
116
+ },
117
+ {
118
+ id: 'theme.toggle',
119
+ label: 'Toggle light / dark',
120
+ shortcut: 'mod+shift+l',
121
+ group: 'theme',
122
+ run: () => { vibe.mode = vibe.mode === 'dark' ? 'light' : 'dark' }
123
+ }
124
+ ])
125
+ })
126
+ </script>
127
+
128
+ <!-- shortcuts action on the root element intercepts keydown globally -->
129
+ <div use:shortcuts={commands}>
130
+ {@render children()}
131
+ </div>
132
+
133
+ <!-- palette renders as an overlay; it reads commands.all directly -->
134
+ <CommandPalette bind:open={paletteOpen} />
135
+ ```
136
+
137
+ The `shortcuts` action listens on `window` for `keydown` (via Svelte's `on()`), so
138
+ placing it on any element in the tree works — root element or `<svelte:body>` are both fine.
139
+
140
+ ---
141
+
142
+ ## Scope & lifecycle
143
+
144
+ **App-root commands** — register once in the root layout's `onMount`. They live for
145
+ the entire session. The `palette.open` command belongs here; it must always be reachable.
146
+
147
+ **Route or component-scoped commands** — register in the component's `onMount` and
148
+ return the unregister fn. They are removed when the component unmounts:
149
+
150
+ ```svelte
151
+ <script>
152
+ import { onMount } from 'svelte'
153
+ import { commands } from '@rokkit/states'
154
+
155
+ onMount(() => {
156
+ // These commands are only active while this component is mounted
157
+ return commands.registerMany([
158
+ {
159
+ id: 'editor.save',
160
+ label: 'Save document',
161
+ shortcut: 'mod+s',
162
+ group: 'editor',
163
+ run: saveDocument
164
+ },
165
+ {
166
+ id: 'editor.format',
167
+ label: 'Format code',
168
+ shortcut: 'mod+shift+f',
169
+ group: 'editor',
170
+ run: formatCode
171
+ }
172
+ ])
173
+ })
174
+ </script>
175
+ ```
176
+
177
+ `commands.all` is reactive (`$state` inside the registry), so `CommandPalette` and
178
+ any list reading `commands.all` automatically re-renders when commands are added or removed.
179
+
180
+ ---
181
+
182
+ ## Shortcut grammar
183
+
184
+ `mod` resolves to `Cmd` on macOS, `Ctrl` on Linux/Windows — use it for all
185
+ primary shortcuts so they are cross-platform by default.
186
+
187
+ | Shortcut string | Fires on |
188
+ |-----------------|----------|
189
+ | `'mod+k'` | Cmd+K (macOS) / Ctrl+K (other) |
190
+ | `'mod+shift+l'` | Cmd+Shift+L / Ctrl+Shift+L |
191
+ | `'mod+shift+n'` | Cmd+Shift+N / Ctrl+Shift+N |
192
+ | `'?'` | `?` key (no modifier) |
193
+ | `'alt+t'` | Option+T (macOS) / Alt+T (other) |
194
+
195
+ **`global: true` is required for any shortcut that must fire while a text input is focused.**
196
+ Without it the `shortcuts` action skips the command when `document.activeElement` is an
197
+ `<input>`, `<textarea>`, `<select>`, or `contenteditable`. The `palette.open` command
198
+ (`mod+k`) must always be `global: true` — the palette is most useful when the composer
199
+ or a form field is focused.
200
+
201
+ **Conflict detection:** the registry normalizes shortcuts on registration (modifiers
202
+ sorted by canonical order, key lowercased, `mod` resolved). If a second command registers
203
+ the same normalized shortcut it is warned and skipped — the first registrant wins.
204
+
205
+ **Single chords only:** the grammar is one key + modifiers. Multi-chord sequences
206
+ (e.g. Vim-style `g g`) are not supported.
207
+
208
+ ---
209
+
210
+ ## i18n
211
+
212
+ The palette chrome comes from `messages.command.*` — override once to localize globally:
213
+
214
+ ```js
215
+ import { messages } from '@rokkit/states'
216
+
217
+ messages.set({
218
+ command: {
219
+ placeholder: 'Befehl ausführen…',
220
+ noResults: 'Keine Befehle gefunden',
221
+ label: 'Befehlspalette'
222
+ }
223
+ })
224
+ ```
225
+
226
+ Or register a named locale and switch:
227
+
228
+ ```js
229
+ messages.register('de', {
230
+ command: {
231
+ placeholder: 'Befehl ausführen…',
232
+ noResults: 'Keine Befehle gefunden',
233
+ label: 'Befehlspalette'
234
+ }
235
+ })
236
+ messages.setLocale('de')
237
+ ```
238
+
239
+ Command `label` and `keywords` are consumer-supplied strings — localize them
240
+ directly in the object passed to `register` / `registerMany`.
241
+
242
+ Default English values: `placeholder: 'Run a command…'`, `noResults: 'No commands found'`,
243
+ `label: 'Command palette'`.
244
+
245
+ ---
246
+
247
+ ## Common mistakes
248
+
249
+ | Mistake | Why it fails | Fix |
250
+ |---------|-------------|-----|
251
+ | Registering at module top-level (not in `onMount`) | The registry is a singleton; commands pile up across hot-reloads in dev and are never cleaned up on unmount | Always register in `onMount`, return the unregister fn |
252
+ | Forgetting `global: true` on `mod+k` (or any Cmd-shortcut) | `shortcuts` skips non-global commands while a text input is focused — Cmd+K fired in the composer silently no-ops | Add `global: true` to any command that must fire during text input |
253
+ | Hand-rolling `window.addEventListener('keydown', ...)` | No conflict detection, no `enabled()` gate, no `commands.all` integration, no cleanup | Use `use:shortcuts={commands}` — it handles all of these |
254
+ | Using `<svelte:body use:shortcuts />` without noting that `shortcuts` uses `window` | The action attaches a `window` listener regardless of which element it's placed on — placing it on body is fine but redundant | Place on any element in the tree; root element or body both work |
255
+ | Putting `run: () => paletteOpen = true` at module scope | `paletteOpen` is `$state` in the component — the closure captures the right reference only when created inside the component function | Define `run` functions after `$state` declarations, inside the component |
256
+ | Expecting `commands.all` to include disabled commands without `enabled` | `enabled` is a dynamic gate called at dispatch time, not a filter on the list | `commands.all` always contains all registered commands; `enabled` only gates execution |
@@ -0,0 +1,224 @@
1
+ ---
2
+ name: skin-system-rokkit
3
+ description: Use when switching color skins (which palette backs paper/ink/primary/accent) in a Rokkit (Svelte 5) app — the vibe.skin state, the data-skin mechanism, themable sync, defining skins in rokkit.config.js (skins:), built-in skins, SkinSwitcherToggle, and the skinnable action for dynamic/runtime skins.
4
+ ---
5
+
6
+ # Rokkit Skin System
7
+
8
+ A **skin** is a named colormap — it selects which palette fills each semantic role
9
+ (`surface`, `ink`, `primary`, `accent`, …). Swapping the skin changes the entire palette
10
+ backing `--paper`, `--ink`, `--primary`, `--accent`, and all their derivatives. Named tokens
11
+ (`bg-paper`, `text-ink`, `bg-primary`) keep working unchanged; only the palette under them
12
+ shifts.
13
+
14
+ **Skin vs style vs mode:**
15
+
16
+ | Dimension | Controls | Example values |
17
+ |-----------|----------|----------------|
18
+ | **skin** | which palette backs the roles | `default` · `ocean` · `violet` |
19
+ | **style** | which visual theme (border radius, shadow, surface recipe) | `rokkit` · `minimal` · `zen-sumi` |
20
+ | **mode** | light or dark rendering | `light` · `dark` |
21
+
22
+ These three are orthogonal — a skin switch does not change the style or mode.
23
+
24
+ ---
25
+
26
+ ## Defining skins
27
+
28
+ Declare named skins in `rokkit.config.js` under `skins:`. Each key is a skin name; each
29
+ value maps role names to palette names. **Use `skins:` OR `skin:`, not both** — `skins:`
30
+ wins when both are present.
31
+
32
+ ```js
33
+ // rokkit.config.js
34
+ export default {
35
+ skins: {
36
+ default: {
37
+ surface: { light: 'kami', dark: 'sumi' }, // dual-palette → emits dark block
38
+ ink: { light: 'kami', dark: 'sumi' },
39
+ primary: 'shu',
40
+ accent: 'shu',
41
+ success: 'hisui', warning: 'kohaku', danger: 'shu', error: 'shu', info: 'kohaku',
42
+ },
43
+ ocean: { surface: 'slate', ink: 'slate', primary: 'sky', accent: 'teal' },
44
+ violet: { surface: 'zinc', ink: 'zinc', primary: 'violet', accent: 'indigo' },
45
+ rose: { surface: 'stone', ink: 'stone', primary: 'rose', accent: 'orange' },
46
+ emerald: { surface: 'slate', ink: 'slate', primary: 'emerald', accent: 'cyan' },
47
+ },
48
+ // ... palettes, colorSpace, tokens, etc.
49
+ }
50
+ ```
51
+
52
+ **Dual-palette roles** (`{ light, dark }`) tell the preset to emit both a light block and a
53
+ `[data-mode='dark']` override block. Single-palette roles (e.g. `primary: 'shu'`) stay
54
+ constant across modes — correct for brand accents.
55
+
56
+ ### Built-in skins (available out of the box)
57
+
58
+ The preset ships five built-in skins in `@rokkit/unocss`. Their `[data-skin='name']` CSS
59
+ blocks are emitted automatically — no config needed to use them:
60
+
61
+ | Skin | Surface | Ink | Primary | Accent |
62
+ |------|---------|-----|---------|--------|
63
+ | `default` | (DEFAULT_THEME_MAPPING) | `slate` | — | — |
64
+ | `ocean` | `slate` | `slate` | `sky` | `teal` |
65
+ | `violet` | `zinc` | `zinc` | `violet` | `indigo` |
66
+ | `rose` | `stone` | `stone` | `rose` | `orange` |
67
+ | `emerald` | `slate` | `slate` | `emerald` | `cyan` |
68
+
69
+ Consumer `skins:` in `rokkit.config.js` merge on top and can override or extend these.
70
+
71
+ ---
72
+
73
+ ## Switching at runtime
74
+
75
+ Three moving parts: **`vibe.skin`** (reactive state), **`themable`** (writes `data-skin` to
76
+ DOM), **`SkinSwitcherToggle`** (ready-made control).
77
+
78
+ ### 1. Wire once in the root layout
79
+
80
+ Set `vibe.allowedSkins` **before** `themable` runs — persisted skin can only restore if the
81
+ value is already in the allowed list when `themable` calls `theme.load(storageKey)`.
82
+
83
+ ```svelte
84
+ <!-- src/routes/+layout.svelte -->
85
+ <script>
86
+ import { vibe } from '@rokkit/states'
87
+ import { themable } from '@rokkit/actions'
88
+
89
+ // MUST be set before themable so a persisted skin restores correctly.
90
+ vibe.allowedSkins = ['default', 'ocean', 'violet', 'rose', 'emerald']
91
+ </script>
92
+
93
+ <svelte:body use:themable={{ theme: vibe, storageKey: 'app-theme' }} />
94
+ ```
95
+
96
+ `themable` writes `data-skin={vibe.skin}` on `<body>` (and mirrors to `<html>`) reactively.
97
+ When `storageKey` is provided it also loads/saves the persisted skin.
98
+
99
+ ### 2. Drop in the toggle
100
+
101
+ ```svelte
102
+ <script>
103
+ import { SkinSwitcherToggle } from '@rokkit/app'
104
+ </script>
105
+
106
+ <!-- Reads vibe.allowedSkins by default -->
107
+ <SkinSwitcherToggle />
108
+
109
+ <!-- Or pass an explicit list (same names as skins: in config) -->
110
+ <SkinSwitcherToggle skins={['default', 'ocean', 'violet', 'rose', 'emerald']} />
111
+
112
+ <!-- With labels -->
113
+ <SkinSwitcherToggle
114
+ skins={[
115
+ { name: 'default', label: 'Zen Sumi' },
116
+ { name: 'ocean', label: 'Ocean' },
117
+ { name: 'violet', label: 'Violet' },
118
+ ]}
119
+ showLabels
120
+ size="md"
121
+ />
122
+ ```
123
+
124
+ **`SkinSwitcherToggle` props:**
125
+
126
+ | Prop | Type | Default | Purpose |
127
+ |------|------|---------|---------|
128
+ | `skins` | `Array<string \| { name, label?, icon? }>` | `vibe.allowedSkins` | skin options |
129
+ | `labels` | `Record<string, string>` | `{}` | display labels keyed by name |
130
+ | `showLabels` | `boolean` | `false` | show label text in the toggle |
131
+ | `size` | `'sm' \| 'md' \| 'lg'` | `'sm'` | control size |
132
+ | `disabled` | `boolean` | `false` | disable the control |
133
+ | `onchange` | `(skin: string) => void` | — | callback after switching |
134
+
135
+ The component sets `vibe.skin` directly — no extra wiring needed.
136
+
137
+ ### 3. Switch programmatically
138
+
139
+ ```svelte
140
+ <button onclick={() => (vibe.skin = 'ocean')}>Ocean</button>
141
+ ```
142
+
143
+ `vibe.skin`'s setter validates against `vibe.allowedSkins` — the assignment is a no-op if
144
+ the skin name is not in the allowed list.
145
+
146
+ ---
147
+
148
+ ## How the CSS works
149
+
150
+ The UnoCSS preset emits one CSS block per skin:
151
+
152
+ ```css
153
+ /* default skin on :root — always active as fallback */
154
+ :root {
155
+ --paper: oklch(0.985 0.005 85);
156
+ --ink: oklch(0.220 0.012 50);
157
+ --primary: oklch(0.580 0.150 35);
158
+ /* ... all named tokens ... */
159
+ }
160
+
161
+ /* named skin blocks — activated by data-skin attribute */
162
+ [data-skin='ocean'] {
163
+ --paper: /* slate.50 */;
164
+ --ink: /* slate.900 */;
165
+ --primary: /* sky.500 */;
166
+ /* ... */
167
+ }
168
+
169
+ /* dark mode override for dual-palette skins (single compound selector) */
170
+ [data-mode='dark'][data-skin='ocean'] {
171
+ --paper: /* slate.950 (inverted) */;
172
+ --ink: /* slate.50 (inverted) */;
173
+ }
174
+ ```
175
+
176
+ `themable` keeps `data-skin` on `<body>` (and `<html>`) in sync with `vibe.skin`. CSS
177
+ specificity does the rest — no JavaScript skin loops, no `classList` juggling.
178
+
179
+ **The old `skin-{name}` class is removed.** Do not use `class="skin-ocean"` — it no longer
180
+ exists. `data-skin='ocean'` is the only mechanism.
181
+
182
+ ---
183
+
184
+ ## Dynamic / custom skins (skinnable)
185
+
186
+ For skins that are not pre-baked in config (server-configured palettes, user-customized
187
+ color pickers, multi-tenant theming), use the `skinnable` action from `@rokkit/actions`.
188
+ It applies CSS custom properties inline on a specific element, scoped to that element's
189
+ subtree.
190
+
191
+ ```svelte
192
+ <script>
193
+ import { skinnable } from '@rokkit/actions'
194
+
195
+ // varMap: CSS var name → complete color value
196
+ const brandVars = {
197
+ '--primary': 'oklch(0.60 0.18 260)',
198
+ '--accent': 'oklch(0.55 0.15 200)',
199
+ '--paper': 'oklch(0.97 0.005 260)',
200
+ '--ink': 'oklch(0.22 0.010 260)',
201
+ }
202
+ </script>
203
+
204
+ <div use:skinnable={brandVars}>
205
+ <!-- descendants resolve CSS vars from inline styles -->
206
+ </div>
207
+ ```
208
+
209
+ `skinnable` is complementary to the `data-skin` cascade, not a replacement. Use
210
+ `vibe.skin` + `themable` for app-wide named skins; use `skinnable` for scoped or
211
+ runtime-only color overrides.
212
+
213
+ ---
214
+
215
+ ## Common mistakes
216
+
217
+ | Mistake | Why it fails | Fix |
218
+ |---------|-------------|-----|
219
+ | `vibe.allowedSkins` set after `themable` runs | persisted skin can't restore; `vibe.skin = storedValue` is a no-op because the list defaults to `['default']` only | set `vibe.allowedSkins` before `<svelte:body use:themable ...>` in the root layout |
220
+ | `class="skin-ocean"` on an element | `skin-{name}` class was removed | use `data-skin='ocean'` attribute, or let `themable` write it via `vibe.skin` |
221
+ | Hand-rolling `<style>` injection for a skin | brittle, order-dependent, bypasses named-token aliases | define in `rokkit.config.js` `skins:` + let the preset emit `[data-skin='name']` blocks |
222
+ | `vibe.skin = 'ocean'` with no `allowedSkins` set | setter rejects unknown values | set `vibe.allowedSkins = ['default', 'ocean', ...]` first |
223
+ | `skins:` and `skin:` both in config | `skins:` silently wins; `skin:` is ignored | pick one |
224
+ | Single-palette `surface` in a skin (`surface: 'slate'`) expecting dark-mode flip | no `[data-mode='dark']` block is emitted for single-palette roles | use dual-palette `surface: { light: 'kami', dark: 'sumi' }` for dark support |
package/src/doctor.js CHANGED
@@ -5,11 +5,11 @@ import { loadConfig as loadParsedConfig } from './config.js'
5
5
  import {
6
6
  generateUnoConfig,
7
7
  generateAppCssImports,
8
- generateInitScript,
9
8
  generateChartConfig,
10
9
  generateConfig,
11
10
  serializeRokkitConfig
12
11
  } from './init.js'
12
+ import { themeInitScript } from '@rokkit/unocss/hooks'
13
13
 
14
14
  const KNOWN_THEMES = [
15
15
  'rokkit',
@@ -220,8 +220,15 @@ function applyPatchHtml(cwd, label) {
220
220
  const htmlPath = resolve(cwd, 'src/app.html')
221
221
  if (!existsSync(htmlPath)) return false
222
222
  const html = readFileSync(htmlPath, 'utf-8')
223
- const script = generateInitScript('manual', 'rokkit-theme')
224
- if (!script || html.includes('rokkit-theme')) return false
223
+ // Use the project's configured storageKey so the injected script reads the
224
+ // same localStorage slot the app persists to — don't hardcode a key.
225
+ const configPath = resolve(cwd, 'rokkit.config.js')
226
+ const storageKey = existsSync(configPath)
227
+ ? readFileSync(configPath, 'utf-8').match(/storageKey:\s*['"]([^'"]+)['"]/)?.[1]
228
+ : undefined
229
+ const marker = storageKey || 'rokkit-theme'
230
+ if (html.includes(marker)) return false
231
+ const script = themeInitScript({ storageKey })
225
232
  const patched = html.replace(/(<body[^>]*>)/, `$1\n${script}`)
226
233
  writeFileSync(htmlPath, patched)
227
234
  console.info(` Fixed: ${label}`)
package/src/init.js CHANGED
@@ -4,6 +4,7 @@ import { writeFileSync, readFileSync, existsSync } from 'fs'
4
4
  import { resolve } from 'path'
5
5
  import { execFileSync } from 'child_process'
6
6
  import { detectPackageManager, buildInstallCommand } from './upgrade.js'
7
+ import { themeInitScript } from '@rokkit/unocss/hooks'
7
8
 
8
9
  const ROKKIT_PACKAGES = ['@rokkit/ui', '@rokkit/unocss', '@rokkit/themes', '@rokkit/icons']
9
10
  const LOCKFILES = ['bun.lock', 'bun.lockb', 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json']
@@ -25,6 +26,9 @@ const NAMED_TOKEN_HEADER = `/**
25
26
  *
26
27
  * tokens: 'core' emits the named vocabulary; 'extended' also emits the full
27
28
  * 11-shade palette ladder per role (for charts / data-viz).
29
+ *
30
+ * Also: \`skins\` (named alternates → \`[data-skin]\` blocks), \`switcher\`
31
+ * ('system'|'manual'|'full'), \`storageKey\`, \`shape\`, \`typography\`.
28
32
  */
29
33
  `
30
34
 
@@ -34,6 +38,21 @@ const OKLCH_PALETTES_NOTE = `/**
34
38
  */
35
39
  `
36
40
 
41
+ /**
42
+ * Derive a safe localStorage key from an npm package name.
43
+ * Strips the scope, keeps case, maps unsafe chars to '-'. Returns '' when there
44
+ * is no usable name — the caller then omits `storageKey` rather than baking in a
45
+ * hardcoded default (the consumer config / library fallback decides instead).
46
+ * @param {string} [name]
47
+ * @returns {string}
48
+ */
49
+ export function storageKeyFromName(name) {
50
+ return String(name ?? '')
51
+ .replace(/^@/, '')
52
+ .replace(/[^a-zA-Z0-9_-]+/g, '-')
53
+ .replace(/^-+|-+$/g, '')
54
+ }
55
+
37
56
  /**
38
57
  * Serialize a rokkit config object to a JS module string with a header comment.
39
58
  * @param {Record<string, unknown>} config
@@ -201,11 +220,12 @@ export function generateChartConfig({ chartColors, chartShades }) {
201
220
  /**
202
221
  * Build the Zen-Sumi OKLCH starter (ink-on-paper, dual-palette dark mode).
203
222
  * @param {{ themes?: string[], defaultTheme?: string, switcher?: string,
204
- * includeChart?: boolean, chartColors?: string, chartShades?: string }} [opts]
223
+ * includeChart?: boolean, chartColors?: string, chartShades?: string,
224
+ * storageKey?: string }} [opts]
205
225
  * @returns {Record<string, unknown>}
206
226
  */
207
227
  export function generateZenSumiConfig(opts = {}) {
208
- const { themes, defaultTheme, switcher, includeChart, chartColors, chartShades } = opts
228
+ const { themes, defaultTheme, switcher, includeChart, chartColors, chartShades, storageKey } = opts
209
229
  const config = {
210
230
  palettes: ZEN_SUMI_PALETTES,
211
231
  colorSpace: 'oklch',
@@ -230,7 +250,7 @@ export function generateZenSumiConfig(opts = {}) {
230
250
  themes: themes && themes.length ? themes : ['rokkit', 'zen-sumi'],
231
251
  defaultTheme: defaultTheme || 'zen-sumi',
232
252
  switcher: switcher || 'full',
233
- storageKey: 'rokkit-theme'
253
+ ...(storageKey ? { storageKey } : {})
234
254
  }
235
255
  if (includeChart) config.chart = generateChartConfig({ chartColors, chartShades })
236
256
  return config
@@ -242,7 +262,8 @@ export function generateZenSumiConfig(opts = {}) {
242
262
  * optional `icons` and `chart` sections.
243
263
  * @param {{ palette: string, customColors?: Record<string, string>, icons?: string,
244
264
  * iconPath?: string, iconStyle?: string, themes: string[], defaultTheme?: string,
245
- * switcher: string, includeChart?: boolean, chartColors?: string, chartShades?: string }} opts
265
+ * switcher: string, includeChart?: boolean, chartColors?: string, chartShades?: string,
266
+ * storageKey?: string }} opts
246
267
  * @returns {Record<string, unknown>}
247
268
  */
248
269
  export function generateConfig({
@@ -256,20 +277,22 @@ export function generateConfig({
256
277
  switcher,
257
278
  includeChart,
258
279
  chartColors,
259
- chartShades
280
+ chartShades,
281
+ storageKey
260
282
  }) {
261
283
  if (palette === 'zen-sumi') {
262
- return generateZenSumiConfig({ themes, defaultTheme, switcher, includeChart, chartColors, chartShades })
284
+ return generateZenSumiConfig({ themes, defaultTheme, switcher, includeChart, chartColors, chartShades, storageKey })
263
285
  }
264
286
 
265
287
  const config = {
266
288
  skin: resolveSkin(palette, customColors),
267
289
  colorSpace: 'rgb',
268
290
  tokens: 'core',
291
+ shape: { radius: 'soft' },
269
292
  themes,
270
293
  defaultTheme: defaultTheme || themes[0],
271
294
  switcher,
272
- storageKey: 'rokkit-theme'
295
+ ...(storageKey ? { storageKey } : {})
273
296
  }
274
297
 
275
298
  const iconConfig = {}
@@ -312,50 +335,6 @@ export function generateAppCssImports(themes) {
312
335
  return lines
313
336
  }
314
337
 
315
- /**
316
- * Generate the flash-prevention script for app.html.
317
- *
318
- * Themes flip on `[data-mode="dark"]` only — there is no `@media
319
- * (prefers-color-scheme)` fallback — so every switcher needs a script that
320
- * resolves the mode pre-paint (mirrors `@rokkit/unocss`'s `themeHook`):
321
- * - `system` tracks the OS preference directly (no persisted toggle).
322
- * - `manual` / `full` read the persisted mode and resolve a stored-or-default
323
- * `'system'`/`'auto'` against the OS preference (default: follow the OS).
324
- *
325
- * @param {string} switcher — 'system' | 'manual' | 'full'
326
- * @param {string} [storageKey='rokkit-theme']
327
- * @param {string} [defaultStyle='rokkit']
328
- * @returns {string} the `<script>` tag to inject after `<body>`
329
- */
330
- export function generateInitScript(switcher, storageKey = 'rokkit-theme', defaultStyle = 'rokkit') {
331
- if (switcher === 'system') {
332
- return ` <script>
333
- (function () {
334
- try {
335
- document.body.dataset.mode =
336
- matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
337
- } catch (e) {}
338
- })()
339
- </script>`
340
- }
341
-
342
- const setStyle =
343
- switcher === 'full' ? `b.dataset.style = t.style || '${defaultStyle}'\n ` : ''
344
-
345
- return ` <script>
346
- (function () {
347
- try {
348
- var t = JSON.parse(localStorage.getItem('${storageKey}') || '{}')
349
- var b = document.body
350
- ${setStyle}var m = t.mode || 'system'
351
- if (m === 'system' || m === 'auto')
352
- m = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
353
- b.dataset.mode = m
354
- } catch (e) {}
355
- })()
356
- </script>`
357
- }
358
-
359
338
  const PROMPTS_CONFIG = [
360
339
  {
361
340
  type: 'select',
@@ -375,12 +354,6 @@ const PROMPTS_CONFIG = [
375
354
  message: 'Primary color (Tailwind palette name)',
376
355
  initial: 'orange'
377
356
  },
378
- {
379
- type: (_, values) => (values.palette === 'custom' ? 'text' : null),
380
- name: 'secondary',
381
- message: 'Secondary color',
382
- initial: 'pink'
383
- },
384
357
  {
385
358
  type: (_, values) => (values.palette === 'custom' ? 'text' : null),
386
359
  name: 'accent',
@@ -578,19 +551,26 @@ export async function init(_opts = {}, adapters = {}) {
578
551
  if (response.palette === 'custom') {
579
552
  response.customColors = {
580
553
  primary: response.primary,
581
- secondary: response.secondary,
582
554
  accent: response.accent,
583
555
  surface: response.surface
584
556
  }
585
557
  }
586
558
 
559
+ let appName
560
+ try {
561
+ appName = JSON.parse(readFileSync(resolve(cwd, 'package.json'), 'utf-8')).name
562
+ } catch {
563
+ appName = undefined
564
+ }
565
+ response.storageKey = storageKeyFromName(appName)
566
+
587
567
  const config = generateConfig(response)
588
568
 
589
569
  writeRokkitConfig(cwd, config)
590
570
  writeUnoConfig(cwd)
591
571
  writeAppCss(cwd, generateAppCssImports(config.themes))
592
572
 
593
- const initScript = generateInitScript(config.switcher, config.storageKey, config.defaultTheme)
573
+ const initScript = themeInitScript({ storageKey: config.storageKey, defaultStyle: config.defaultTheme })
594
574
  if (initScript) writeAppHtml(cwd, initScript, config.storageKey)
595
575
 
596
576
  console.info('\nDone! Run `rokkit doctor` to verify your setup.')