@rokkit/cli 1.1.12 → 1.1.14

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.
@@ -0,0 +1,390 @@
1
+ ---
2
+ name: semantic-styles-rokkit
3
+ description: Use when building, styling, or auditing a Rokkit-powered app (Svelte 5 + UnoCSS presetRokkit) — covers the named-token vocabulary (paper / ink / primary / on-primary / *-soft), the rokkit.config.js palette→skin→tokens pipeline, dual-palette dark mode, migrating legacy z-scale utilities (bg-surface-z0, text-primary-z5) to named tokens, and the rokkit init / rokkit doctor CLI.
4
+ ---
5
+
6
+ # Semantic Styles — Rokkit
7
+
8
+ Rokkit's token pipeline turns a small config into a consistent, themeable, dark-mode-ready
9
+ set of utility classes. Components use **named tokens** (`bg-paper`, `text-ink`,
10
+ `text-on-primary`) — never raw hex/oklch values. That indirection is the consistency
11
+ guarantee: change the config, the whole app reskins.
12
+
13
+ ```
14
+ rokkit.config.js
15
+ palettes + skin + colorSpace + tokens
16
+ ↓
17
+ UnoCSS preset (presetRokkit) → CSS custom properties + utility classes
18
+ ↓
19
+ Components use named-token utilities: bg-paper · text-ink · bg-primary · text-on-primary
20
+ (legacy z-scale utilities — bg-surface-z1 — still resolve via back-compat aliases)
21
+ ```
22
+
23
+ The pipeline has three layers. Most authoring happens in **Layer 3 (named tokens)**.
24
+
25
+ ---
26
+
27
+ ## Layer 1 — Palettes
28
+
29
+ **What:** A palette is a named 11-stop color scale from shade `50` (lightest) to `950`
30
+ (darkest), declared in `rokkit.config.js` under `palettes:`.
31
+
32
+ **Why:** Each role needs a range of shades so hover, active, tint-on-surface, and
33
+ text-on-fill all come from one color family. 11 stops gives resolution without banding.
34
+
35
+ **Two formats:**
36
+
37
+ *RGB hex* — standard web colors; use when you have an existing brand palette:
38
+ ```js
39
+ palettes: { shu: { 50: '#FEF3EE', 500: '#E8552B', 950: '#3C100A' } }
40
+ // colorSpace: 'rgb' (default — may be omitted)
41
+ ```
42
+
43
+ *OKLCH bare components* — perceptual, uniform lightness steps; best for palettes designed
44
+ from scratch (increment L by equal steps for balanced results):
45
+ ```js
46
+ palettes: { shu: { 50: '0.970 0.020 35', 500: '0.580 0.150 35', 950: '0.220 0.060 35' } },
47
+ colorSpace: 'oklch' // REQUIRED when palette values are bare "L C H" components
48
+ ```
49
+
50
+ The preset wraps values into complete colors when it emits CSS vars (see Layer 3).
51
+
52
+ ---
53
+
54
+ ## Layer 2 — Skin and skins
55
+
56
+ **What:** A skin maps semantic role names to palette names. The core roles that back the
57
+ named-token vocabulary are: `surface`, `ink`, `primary`, `accent`, `success`, `warning`,
58
+ `danger`, `error`, `info`. (`secondary`/`tertiary` are accepted but no named token reads
59
+ them — they only matter if you also use `extended` z-scale utilities for those roles.)
60
+
61
+ **Why:** Decoupling role from palette lets you swap the whole scheme in config, not code.
62
+ `bg-paper` doesn't know whether `surface` is `kami`, `slate`, or `zinc`.
63
+
64
+ ### Single skin (`skin:`) — one fixed colormap
65
+ ```js
66
+ skin: {
67
+ surface: 'slate', ink: 'slate', // ink backs the ink-* text tokens
68
+ primary: 'orange', accent: 'sky',
69
+ success: 'green', warning: 'yellow', danger: 'red', error: 'red', info: 'cyan',
70
+ }
71
+ ```
72
+ `colors:` is accepted as a back-compat alias but **prefer `skin:`**. Always include an
73
+ `ink` role — without it the `ink-*` text tokens silently fall back to the surface palette.
74
+
75
+ ### Multi-skin (`skins:`) — named colormaps for runtime switching
76
+ ```js
77
+ skins: {
78
+ default: { surface: { light: 'kami', dark: 'sumi' }, ink: { light: 'kami', dark: 'sumi' }, primary: 'shu' },
79
+ ocean: { surface: 'slate', primary: 'sky', accent: 'teal' },
80
+ }
81
+ ```
82
+ `skins.default` is active on first load. **Use `skin:` OR `skins:`, not both** — providing
83
+ `skins:` makes `skin:` ignored.
84
+
85
+ ### `tokens:` — which CSS the preset emits
86
+ ```js
87
+ tokens: 'core' // default — emit the 24 named tokens (palette values inlined) + z-aliases
88
+ tokens: 'extended' // also emit the full 11-shade ladder (--color-{role}-{shade}) per role
89
+ ```
90
+ `'core'` is the default and what apps should use. Reach for `'extended'` only when you need
91
+ the full ladder (charts / data-viz). Per-role is allowed: `tokens: { surface: 'extended' }`.
92
+
93
+ ---
94
+
95
+ ## Layer 2b — Dual-palette dark mode (how the flip actually works)
96
+
97
+ **Named tokens flip in dark mode ONLY when the skin uses a dual-palette `{ light, dark }`
98
+ mapping for a role (or an override carries a `dark` value).** The preset emits a
99
+ `[data-mode="dark"]` block redefining the tokens **only then** — there is no automatic
100
+ z-flip in the themes CSS anymore.
101
+
102
+ ```js
103
+ skin: {
104
+ surface: { light: 'kami', dark: 'sumi' }, // ← emits the dark block
105
+ ink: { light: 'kami', dark: 'sumi' },
106
+ primary: 'shu', // single palette — constant across modes (correct for an accent)
107
+ }
108
+ ```
109
+
110
+ The trick is that the **dark palette is authored inverted** relative to the light one, so
111
+ the same shade index reads correctly per mode:
112
+
113
+ | token (shade) | light (`kami`) | dark (`sumi`) |
114
+ |---------------|-----------------------|-----------------------|
115
+ | `--paper` (50) | `0.985…` (light bg) | `0.170…` (dark bg) |
116
+ | `--ink` (900) | `0.220…` (dark text) | `0.940…` (light text) |
117
+
118
+ > **Gotcha:** a **single-palette** surface (e.g. `surface: 'slate'`) emits **no dark
119
+ > block → no flip** → effectively light-only. For dark mode, make `surface` and `ink`
120
+ > dual-palette (the canonical `kami`/`sumi` OKLCH pair, where `sumi` is authored inverted).
121
+ > The `rokkit init` "Zen-Sumi" starter ships this; the plain Tailwind starter does not.
122
+
123
+ Dark mode flips when `data-mode="dark"` is on `<html>`/`<body>` — see the wiring section
124
+ below for how to apply and persist that correctly.
125
+
126
+ ---
127
+
128
+ ## Applying & persisting the theme (don't hand-roll this)
129
+
130
+ **Never** hand-roll `data-mode` writes, `localStorage` save/load, or your own theme store.
131
+ The pattern is: the **`vibe` store** (`@rokkit/states`) holds reactive state; the
132
+ **`themable` action** (`@rokkit/actions`) syncs it to the DOM + storage; **`ThemeSwitcherToggle`**
133
+ (`@rokkit/app`) is the ready-made control.
134
+
135
+ **1. Wire the root once** (e.g. `+layout.svelte`). `themable` loads from storage on mount,
136
+ writes `data-style`/`data-mode`/`data-density` (mirrored to `<html>` to match the
137
+ flash-prevention init script), **saves on every change**, and syncs across tabs via the
138
+ `storage` event:
139
+
140
+ ```svelte
141
+ <script>
142
+ import { vibe } from '@rokkit/states'
143
+ import { themable } from '@rokkit/actions'
144
+ </script>
145
+ <svelte:body use:themable={{ theme: vibe, storageKey: 'app-theme' }} />
146
+ ```
147
+
148
+ **2. Add the mode control** anywhere — it drives `vibe.mode`, which `themable` then writes:
149
+
150
+ ```svelte
151
+ <script>import { ThemeSwitcherToggle } from '@rokkit/app'</script>
152
+ <ThemeSwitcherToggle variant="single" /> <!-- one button, cycles light ↔ dark -->
153
+ <ThemeSwitcherToggle variant="triad" /> <!-- group: system | light | dark (default) -->
154
+ <ThemeSwitcherToggle variant="triad" includeSystem={false} /> <!-- group: light | dark -->
155
+ ```
156
+
157
+ **3. Switch the visual style** by setting state — no DOM writes:
158
+ ```svelte
159
+ <button onclick={() => (vibe.style = 'minimal')}>Minimal</button>
160
+ ```
161
+
162
+ **4. Prevent the flash on first paint.** Inject the pre-paint script so the persisted
163
+ theme applies before first render. SvelteKit apps use the SSR `themeHook`; static apps use
164
+ the script `rokkit init` writes into `app.html`:
165
+
166
+ ```js
167
+ // src/hooks.server.ts
168
+ import { themeHook } from '@rokkit/unocss/hooks'
169
+ export const handle = themeHook({ storageKey: 'app-theme', defaultMode: 'system' })
170
+ ```
171
+
172
+ `defaultMode: 'system'` (also the default when omitted) resolves `prefers-color-scheme`
173
+ to a concrete `light`/`dark` pre-paint — never an invalid `data-mode="system"`. `'auto'`
174
+ is accepted as a legacy alias. (Requires `@rokkit/unocss` ≥ 1.1.12.)
175
+
176
+ `vibe` (singleton) exposes reactive `mode` (`light`|`dark`), `style`
177
+ (`rokkit`|`minimal`|`material`), `density` (`compact`|`comfortable`|`cozy`), `direction`,
178
+ plus `load(key)` / `save(key)` / `update(partial)`. `ThemeSwitcherToggle` lives in
179
+ `@rokkit/app` (the store-wired shell package), built on `@rokkit/ui`'s `Toggle`; it adds
180
+ `system` on top of `vibe.mode` via `ColorModeManager` (resolving `system` through
181
+ `prefers-color-scheme`). The `storageKey` must be the same everywhere — `themable`,
182
+ `ThemeSwitcherToggle`'s store, and the flash-prevention script (step 4).
183
+
184
+ ---
185
+
186
+ ## Layer 3 — Named tokens (what components use)
187
+
188
+ The 24-token core vocabulary, all flipping automatically under `[data-mode="dark"]`:
189
+
190
+ | Group | Tokens | Use |
191
+ |---------|---------------------------------------------------------|--------------------------------------------------|
192
+ | Surface | `paper` · `paper-soft` · `paper-mute` · `paper-edge` | canvas · card · subdued panel · hairline border |
193
+ | Text | `ink` · `ink-mute` · `ink-soft` · `ink-faint` | body · secondary · placeholder · disabled |
194
+ | Primary | `primary` · `on-primary` | CTA fill · text/icon on a primary fill |
195
+ | Accent | `accent` · `accent-soft` | accent fill · tinted accent background |
196
+ | Status | `success`/`-soft`, `warning`/`-soft`, `danger`/`-soft`, `error`/`-soft`, `info`/`-soft` | solid status · tinted callout bg |
197
+ | Misc | `focus-ring` · `shadow-tint` | focus ring color · shadow color |
198
+
199
+ **Prefixes:** `bg-`, `text-`, `border-` (+ `border-t/-b/-l/-r`), `fill-`, `stroke-`,
200
+ `ring-`, `outline-`, `divide-`. Two constraints: `on-primary` emits **text only**
201
+ (`text-on-primary`); `focus-ring` emits ring/outline/border/divide only; `shadow-tint`
202
+ emits no utility (used inside `box-shadow` expressions).
203
+
204
+ ```html
205
+ <!-- card -->
206
+ <div class="bg-paper-soft border border-paper-edge rounded-md p-4">
207
+ <h3 class="text-ink text-sm font-medium">Title</h3>
208
+ <p class="text-ink-mute text-xs mt-1">Description</p>
209
+ </div>
210
+
211
+ <!-- primary button + status badge -->
212
+ <button class="bg-primary text-on-primary rounded-md px-3 py-2">Save</button>
213
+ <span class="bg-success-soft text-success text-xs px-2 py-0.5 rounded-full">Active</span>
214
+
215
+ <!-- input -->
216
+ <input class="bg-paper border border-paper-edge text-ink rounded-md px-3 py-2
217
+ focus:border-primary focus:outline-none text-[13px]" />
218
+ ```
219
+
220
+ ### CSS custom properties
221
+
222
+ Named tokens emit `--{token}` vars holding **complete color values** —
223
+ `--paper: oklch(0.985 0.005 85)` (oklch) or `--primary: rgb(232, 85, 43)` (rgb). Use them
224
+ directly in CSS-only contexts; for alpha, use `color-mix` (the form the preset itself uses):
225
+ ```css
226
+ .thing { background: var(--paper); border-color: var(--paper-edge); }
227
+ .thing-50pc { background: color-mix(in oklch, var(--primary) 50%, transparent); }
228
+ ```
229
+ Prefer utility classes over raw vars where you can. (In `core` mode the legacy
230
+ `--color-{role}-z{n}` vars are aliases pointing at named tokens, so they too resolve to
231
+ complete colors — the old "wrap bare components in `rgb()`/`oklch()`" rule no longer
232
+ applies in core mode.)
233
+
234
+ ### Legacy z-scale utilities (back-compat)
235
+
236
+ The older z-scale utilities (`bg-surface-z0`, `text-surface-z9`, `text-primary-z5`, …)
237
+ **still resolve** as a back-compat layer that collapses onto the named tokens above —
238
+ existing code keeps working. New code should use named tokens. See the migration section.
239
+
240
+ ---
241
+
242
+ ## Migrating from z-scale to named tokens
243
+
244
+ `rokkit doctor` scans `src/**` and prints the suggested replacement per occurrence
245
+ (advisory — it never rewrites your code). The mapping it uses:
246
+
247
+ **Surface role** (`{prefix}-surface-z{n}` → `{prefix}-{token}`):
248
+
249
+ | z | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
250
+ |---|---|---|---|---|---|---|---|---|---|---|----|
251
+ | token | `paper` | `paper-soft` | `paper-mute` | `paper-mute` | `paper-edge` | `ink-soft` | `ink-soft` | `ink-mute` | `ink-mute` | `ink` | `ink` |
252
+
253
+ **Ink role** (inverted scale):
254
+
255
+ | z | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
256
+ |---|---|---|---|---|---|---|---|---|---|---|----|
257
+ | token | `ink` | `ink-mute` | `ink-mute` | `ink-soft` | `ink-soft` | `paper-edge` | `paper-edge` | `paper-mute` | `paper-mute` | `paper-soft` | `paper` |
258
+
259
+ **Accent / status roles** (`primary`, `accent`, `success`, `warning`, `danger`, `error`,
260
+ `info`): `z0–z2 → {role}-soft` (tinted background), `z3+ → {role}` (solid). Exception:
261
+ `primary` has **no** `-soft` companion — `primary-z*` all map to `primary`; for a tinted
262
+ primary background use `accent-soft` or a custom token.
263
+
264
+ Common rewrites:
265
+ ```
266
+ bg-surface-z0 → bg-paper text-surface-z9 → text-ink
267
+ bg-surface-z1 → bg-paper-soft text-surface-z7 → text-ink-mute
268
+ border-surface-z3 → border-paper-edge text-surface-z5 → text-ink-soft
269
+ text-primary-z5 → text-primary bg-success-z1 → bg-success-soft
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Rokkit CLI — `init` and `doctor`
275
+
276
+ **`rokkit init`** scaffolds `rokkit.config.js` in the named-token shape (a header comment
277
+ documents the vocabulary). Two starters via the palette prompt:
278
+ - **Tailwind (rgb)** — `skin` of Tailwind palette names, `colorSpace: 'rgb'`, `tokens: 'core'`. Single-palette → light-leaning (see Layer 2b).
279
+ - **Zen-Sumi (OKLCH)** — ships `palettes` (kami/sumi/shu/…), `colorSpace: 'oklch'`, dual-palette `surface`/`ink` → full dark mode out of the box.
280
+
281
+ **`rokkit doctor`** validates an existing setup. Beyond file checks (config exists,
282
+ `uno.config.js` uses `presetRokkit(config)`, `app.css` imports a theme, `app.html` has the
283
+ mode init script), it runs **advisory** config-shape warnings and migration hints — none
284
+ fail the command or block `--fix`:
285
+
286
+ | Check | Warns when |
287
+ |-------|-----------|
288
+ | `skin-ink-role` | the active colormap has no `ink` role (text tokens fall back) |
289
+ | `oklch-needs-palettes` | `colorSpace: 'oklch'` but no `palettes` block |
290
+ | `colors-alias` | config uses the legacy `colors:` instead of `skin:` |
291
+ | legacy z-utilities | `src/**` contains `bg-surface-z*` / `text-primary-z*` etc. (prints named-token suggestions) |
292
+
293
+ The "active colormap" is resolved the same way the preset does: `skins.default ?? skin ??
294
+ colors`. Run `rokkit doctor --fix` to auto-write a real starter config when one is missing.
295
+
296
+ ---
297
+
298
+ ## Typography configuration
299
+
300
+ Set faces in config; the preset emits CSS vars and Tailwind `fontFamily` keys.
301
+ ```js
302
+ typography: {
303
+ display: "'Fraunces', 'Iowan Old Style', Georgia, serif", // heading/display face
304
+ sans: "'Inter', system-ui, sans-serif", // UI/body face (alias: `ui`)
305
+ mono: "'JetBrains Mono', 'SF Mono', Menlo, monospace",
306
+ }
307
+ ```
308
+ Canonical config keys are `display` / `ui` / `mono`; `heading` / `sans` are accepted
309
+ aliases. Usage classes: `font-heading` (display face), `font-body` (UI face), `font-mono`.
310
+ ```html
311
+ <h1 class="font-heading text-2xl text-ink">Page Title</h1>
312
+ <p class="font-body text-[13px] text-ink-mute">Description</p>
313
+ <code class="font-mono text-xs text-ink-soft">path/to/file.ts</code>
314
+ ```
315
+
316
+ ---
317
+
318
+ ## Shape / radius configuration
319
+
320
+ `shape: { radius: 'soft' }` selects a preset emitting `--radius-{sm,md,lg,xl,full}`.
321
+
322
+ | Preset | sm | md | lg | xl | full | Personality |
323
+ |--------|----|----|----|----|------|-------------|
324
+ | `sharp` | 0 | 0 | 0 | 0 | 9999px | Minimal, editorial |
325
+ | `soft` | 2px | 6px | 10px | 12px | 9999px | Professional, calm |
326
+ | `rounded` | 4px | 8px | 12px | 16px | 9999px | Friendly, modern |
327
+ | `pill` | 9999px | 9999px | 9999px | 9999px | 9999px | Playful, pill-native |
328
+
329
+ Custom: `shape: { radius: { sm: '2px', md: '6px', lg: '10px', xl: '14px', full: '9999px' } }`.
330
+ Use the Tailwind radius utilities that map to the vars (`rounded-md`, `rounded-lg`, …) —
331
+ never `rounded-[6px]`, which bypasses the config.
332
+
333
+ ---
334
+
335
+ ## Spacing
336
+
337
+ Rokkit inherits Tailwind's 4px-grid spacing. **Stay on the 4px grid, use Tailwind names.**
338
+
339
+ | Tailwind | px | When |
340
+ |----------|----|------|
341
+ | `1` / `2` / `3` | 4 / 8 / 12 | tight gap · component gap · compact padding |
342
+ | `4` / `6` | 16 / 24 | default padding · panel padding |
343
+ | `8` / `12` / `16` | 32 / 48 / 64 | section separation · page padding · hero whitespace |
344
+
345
+ Density: **compact** (tables/sidebars) `gap-1`/`gap-2`, `p-2`/`p-3`; **comfortable**
346
+ (cards/forms) `gap-3`/`gap-4`, `p-4`/`p-6`; **relaxed** (marketing) `16–24px+`.
347
+
348
+ ---
349
+
350
+ ## Full config example
351
+
352
+ ```js
353
+ // rokkit.config.js
354
+ export default {
355
+ palettes: {
356
+ kami: { 50: '0.985 0.005 85', /* … */ 950: '0.170 0.010 50' }, // light surface (warm paper)
357
+ sumi: { 50: '0.170 0.010 50', /* … */ 950: '0.975 0.008 85' }, // dark surface (inverted ink)
358
+ shu: { 50: '0.970 0.020 35', 500: '0.580 0.150 35', 950: '0.220 0.060 35' }, // accent
359
+ },
360
+ colorSpace: 'oklch', // required for bare "L C H" palette values
361
+ tokens: 'core', // emit the named-token vocabulary (default)
362
+ skin: {
363
+ surface: { light: 'kami', dark: 'sumi' }, // dual-palette → dark mode
364
+ ink: { light: 'kami', dark: 'sumi' },
365
+ primary: 'shu', accent: 'shu',
366
+ success: 'hisui', warning: 'kohaku', danger: 'shu', error: 'shu', info: 'kohaku',
367
+ },
368
+ typography: { display: "'Fraunces', Georgia, serif", sans: "'Inter', sans-serif", mono: "'JetBrains Mono', monospace" },
369
+ shape: { radius: 'soft' },
370
+ switcher: 'full', // 'system' | 'manual' | 'full'
371
+ storageKey: 'app-theme',
372
+ }
373
+ ```
374
+
375
+ ---
376
+
377
+ ## Common mistakes
378
+
379
+ | Mistake | Why it fails | Fix |
380
+ |---|---|---|
381
+ | `color: #3D3730` in a component | breaks on theme change | `text-ink` (or the right named token) |
382
+ | Using `bg-surface-z5` / `text-primary-z5` in new code | legacy back-compat layer | named tokens — see migration table (or run `rokkit doctor`) |
383
+ | Single-palette `surface` but expecting dark mode | no `[data-mode]` block is emitted | make `surface`/`ink` dual-palette `{ light, dark }` |
384
+ | Skin with no `ink` role | `ink-*` text tokens fall back to surface | add `ink: '<palette>'` (reuse surface is fine) |
385
+ | Using `skin:` and `skins:` together | `skins:` silently wins | pick one |
386
+ | `oklch(0.58 0.15 35)` literal in a component | loses theming + composability | `bg-primary` / `var(--primary)` |
387
+ | Double-wrapping in core mode: `oklch(var(--paper))` | `--paper` is already a complete color | use `var(--paper)` directly |
388
+ | `colorSpace: 'oklch'` with hex palettes (or vice-versa) | wrap form won't match values | match `colorSpace` to the palette format |
389
+ | `border-radius: 6px` / `font-family: 'Inter'` hardcoded | bypasses shape/typography config | `rounded-md` / `font-body` |
390
+ | `@apply bg-paper` on `body {}` | descendant-combinator shortcut can't match `body` when `data-mode` is on it | `body { background: var(--paper); }` |
package/src/index.js CHANGED
@@ -121,4 +121,22 @@ prog
121
121
  await theme('create', opts)
122
122
  })
123
123
 
124
+ prog
125
+ .command('skills list')
126
+ .describe('List available Rokkit AI skills')
127
+ .action(async () => {
128
+ const { skillsCommand } = await import('./skills.js')
129
+ await skillsCommand('list')
130
+ })
131
+
132
+ prog
133
+ .command('skills add')
134
+ .describe('Add Rokkit AI skills to .claude/skills/')
135
+ .option('--all', 'Install all available skills')
136
+ .option('--force', 'Overwrite an existing skill')
137
+ .action(async (opts) => {
138
+ const { skillsCommand } = await import('./skills.js')
139
+ await skillsCommand('add', opts)
140
+ })
141
+
124
142
  prog.parse(process.argv)
package/src/init.js CHANGED
@@ -314,13 +314,30 @@ export function generateAppCssImports(themes) {
314
314
 
315
315
  /**
316
316
  * Generate the flash-prevention script for app.html.
317
- * Returns null when no script is needed (system switcher).
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
+ *
318
325
  * @param {string} switcher — 'system' | 'manual' | 'full'
319
326
  * @param {string} [storageKey='rokkit-theme']
320
- * @returns {string | null}
327
+ * @param {string} [defaultStyle='rokkit']
328
+ * @returns {string} the `<script>` tag to inject after `<body>`
321
329
  */
322
330
  export function generateInitScript(switcher, storageKey = 'rokkit-theme', defaultStyle = 'rokkit') {
323
- if (switcher === 'system') return null
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
+ }
324
341
 
325
342
  const setStyle =
326
343
  switcher === 'full' ? `b.dataset.style = t.style || '${defaultStyle}'\n ` : ''
@@ -330,7 +347,10 @@ export function generateInitScript(switcher, storageKey = 'rokkit-theme', defaul
330
347
  try {
331
348
  var t = JSON.parse(localStorage.getItem('${storageKey}') || '{}')
332
349
  var b = document.body
333
- ${setStyle}b.dataset.mode = t.mode || 'dark'
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
334
354
  } catch (e) {}
335
355
  })()
336
356
  </script>`
package/src/skills.js ADDED
@@ -0,0 +1,141 @@
1
+ /* eslint-disable no-console */
2
+ import { readFileSync, readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs'
3
+ import { join, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import prompts from 'prompts'
6
+
7
+ // The catalog ships inside the package: packages/cli/skills/ in dev/test,
8
+ // node_modules/@rokkit/cli/skills/ once installed. One relative path, both worlds.
9
+ // Guard against non-file: URLs (e.g. jsdom sets import.meta.url to http://localhost/...).
10
+ const DEFAULT_SKILLS_DIR = (() => {
11
+ try {
12
+ return fileURLToPath(new URL('../skills/', import.meta.url))
13
+ } catch {
14
+ return resolve(process.cwd(), 'packages/cli/skills')
15
+ }
16
+ })()
17
+
18
+ /**
19
+ * Parse `name` and `description` from a SKILL.md YAML frontmatter block.
20
+ * @param {string} md
21
+ * @returns {{ name: string, description: string }}
22
+ */
23
+ export function parseFrontmatter(md) {
24
+ // Keep these as literal regexes — do NOT collapse into a dynamic
25
+ // `new RegExp(`^${key}:...`)` helper: the keys are fixed, so the dynamic form
26
+ // adds nothing but trips the no-dynamic-regex lint, and the literals are
27
+ // plainly linear-time. The `?.[1]?.trim() ?? ''` chain returns '' for a
28
+ // missing block, a missing field, or a bare `key:` with no value.
29
+ const block = md.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''
30
+ const name = block.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? ''
31
+ const description = block.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? ''
32
+ return { name, description }
33
+ }
34
+
35
+ /**
36
+ * Read the bundled skill catalog (derived from each SKILL.md's frontmatter).
37
+ * @param {{ skillsDir?: string }} [opts]
38
+ * @returns {Array<{ name: string, description: string }>}
39
+ */
40
+ export function listSkills({ skillsDir = DEFAULT_SKILLS_DIR } = {}) {
41
+ if (!existsSync(skillsDir)) return []
42
+ return readdirSync(skillsDir)
43
+ .filter((entry) => existsSync(join(skillsDir, entry, 'SKILL.md')))
44
+ .map((entry) => parseFrontmatter(readFileSync(join(skillsDir, entry, 'SKILL.md'), 'utf-8')))
45
+ .filter((s) => s.name)
46
+ .sort((a, b) => a.name.localeCompare(b.name))
47
+ }
48
+
49
+ /**
50
+ * Copy named skills into <cwd>/.claude/skills/<name>/ (idempotent).
51
+ * @param {string[]} names
52
+ * @param {{ cwd?: string, force?: boolean, skillsDir?: string }} [opts]
53
+ * @returns {Array<{ name: string, status: 'added' | 'skipped' | 'unknown' }>}
54
+ */
55
+ export function installSkills(
56
+ names,
57
+ { cwd = process.cwd(), force = false, skillsDir = DEFAULT_SKILLS_DIR } = {}
58
+ ) {
59
+ const targetRoot = resolve(cwd, '.claude', 'skills')
60
+ return names.map((name) => {
61
+ const src = join(skillsDir, name)
62
+ if (!existsSync(join(src, 'SKILL.md'))) return { name, status: 'unknown' }
63
+ const dest = join(targetRoot, name)
64
+ if (existsSync(dest) && !force) return { name, status: 'skipped' }
65
+ mkdirSync(targetRoot, { recursive: true })
66
+ cpSync(src, dest, { recursive: true })
67
+ return { name, status: 'added' }
68
+ })
69
+ }
70
+
71
+ /**
72
+ * Print the catalog, marking already-installed skills.
73
+ * @param {{ skillsDir?: string, cwd?: string }} [opts]
74
+ */
75
+ export async function runSkillsList({ skillsDir, cwd = process.cwd() } = {}) {
76
+ const catalog = listSkills({ skillsDir })
77
+ if (catalog.length === 0) {
78
+ console.info('No skills available.')
79
+ return
80
+ }
81
+ const installedRoot = resolve(cwd, '.claude', 'skills')
82
+ console.info('Available Rokkit skills:\n')
83
+ for (const s of catalog) {
84
+ const mark = existsSync(join(installedRoot, s.name, 'SKILL.md')) ? '✓ ' : ' '
85
+ console.info(`${mark}${s.name}`)
86
+ console.info(` ${s.description}`)
87
+ }
88
+ console.info('\nInstall with: rokkit skills add <name> [...] (or --all)')
89
+ }
90
+
91
+ /**
92
+ * Resolve which skills to install, then copy and report.
93
+ * @param {string[]} names
94
+ * @param {{ all?: boolean, force?: boolean, skillsDir?: string, cwd?: string }} [opts]
95
+ */
96
+ export async function runSkillsAdd(
97
+ names,
98
+ { all = false, force = false, skillsDir, cwd = process.cwd() } = {}
99
+ ) {
100
+ const catalog = listSkills({ skillsDir })
101
+ let selected = names
102
+ if (all) {
103
+ selected = catalog.map((s) => s.name)
104
+ } else if (selected.length === 0) {
105
+ const res = await prompts({
106
+ type: 'multiselect',
107
+ name: 'picked',
108
+ message: 'Select skills to add',
109
+ choices: catalog.map((s) => ({ title: s.name, value: s.name, description: s.description }))
110
+ })
111
+ selected = res.picked ?? []
112
+ }
113
+ if (selected.length === 0) {
114
+ console.info('No skills selected.')
115
+ return
116
+ }
117
+ const results = installSkills(selected, { cwd, force, skillsDir })
118
+ for (const r of results) {
119
+ if (r.status === 'added') console.info(` added .claude/skills/${r.name}`)
120
+ else if (r.status === 'skipped')
121
+ console.info(` skipped ${r.name} (exists — use --force to overwrite)`)
122
+ else console.error(` unknown skill: ${r.name}`)
123
+ }
124
+ if (results.some((r) => r.status === 'unknown')) {
125
+ console.error(`\nValid skills: ${catalog.map((s) => s.name).join(', ')}`)
126
+ process.exitCode = 1
127
+ }
128
+ }
129
+
130
+ /**
131
+ * CLI entry for `rokkit skills` subcommands.
132
+ * @param {'list' | 'add'} sub
133
+ * @param {{ _?: string[], all?: boolean, force?: boolean }} [opts]
134
+ */
135
+ export async function skillsCommand(sub, opts = {}) {
136
+ if (sub === 'add') {
137
+ await runSkillsAdd(opts._ ?? [], { all: opts.all, force: opts.force })
138
+ } else {
139
+ await runSkillsList()
140
+ }
141
+ }