@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.
- package/dist/src/init.d.ts +16 -19
- package/package.json +2 -1
- package/skills/command-system-rokkit/SKILL.md +256 -0
- package/skills/skin-system-rokkit/SKILL.md +224 -0
- package/src/doctor.js +10 -3
- package/src/init.js +39 -59
package/dist/src/init.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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 =
|
|
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.')
|