@rokkit/cli 1.1.12 → 1.1.13
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/README.md +18 -0
- package/dist/src/init.d.ts +11 -3
- package/dist/src/skills.d.ts +63 -0
- package/package.json +2 -1
- package/skills/rokkit-components/SKILL.md +415 -0
- package/skills/semantic-styles-rokkit/SKILL.md +390 -0
- package/src/index.js +18 -0
- package/src/init.js +24 -4
- package/src/skills.js +141 -0
package/README.md
CHANGED
|
@@ -135,3 +135,21 @@ lib/
|
|
|
135
135
|
---
|
|
136
136
|
|
|
137
137
|
Part of [Rokkit](https://github.com/jerrythomas/rokkit) — a Svelte 5 component library and design system.
|
|
138
|
+
|
|
139
|
+
## AI skills
|
|
140
|
+
|
|
141
|
+
Rokkit ships curated AI skills (SKILL.md guides) that teach coding agents how to
|
|
142
|
+
use the library well — theming with the named-token system and building UI with
|
|
143
|
+
the components.
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
rokkit skills list # see the catalog
|
|
147
|
+
rokkit skills add semantic-styles-rokkit # install one
|
|
148
|
+
rokkit skills add # interactive multi-select
|
|
149
|
+
rokkit skills add --all # install everything
|
|
150
|
+
rokkit skills add <name> --force # overwrite an existing install
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Skills install into your project's `.claude/skills/<name>/`, so they're shared
|
|
154
|
+
with your team via version control. Browse the source under
|
|
155
|
+
[`packages/cli/skills/`](./skills).
|
package/dist/src/init.d.ts
CHANGED
|
@@ -77,12 +77,20 @@ export function generateUnoConfig(): string;
|
|
|
77
77
|
export function generateAppCssImports(themes: string[]): string[];
|
|
78
78
|
/**
|
|
79
79
|
* Generate the flash-prevention script for app.html.
|
|
80
|
-
*
|
|
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
|
+
*
|
|
81
88
|
* @param {string} switcher — 'system' | 'manual' | 'full'
|
|
82
89
|
* @param {string} [storageKey='rokkit-theme']
|
|
83
|
-
* @
|
|
90
|
+
* @param {string} [defaultStyle='rokkit']
|
|
91
|
+
* @returns {string} the `<script>` tag to inject after `<body>`
|
|
84
92
|
*/
|
|
85
|
-
export function generateInitScript(switcher: string, storageKey?: string, defaultStyle?: string): string
|
|
93
|
+
export function generateInitScript(switcher: string, storageKey?: string, defaultStyle?: string): string;
|
|
86
94
|
/**
|
|
87
95
|
* Interactive init command — prompts the user, writes config files.
|
|
88
96
|
* @param {Record<string, unknown>} [_opts]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse `name` and `description` from a SKILL.md YAML frontmatter block.
|
|
3
|
+
* @param {string} md
|
|
4
|
+
* @returns {{ name: string, description: string }}
|
|
5
|
+
*/
|
|
6
|
+
export function parseFrontmatter(md: string): {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Read the bundled skill catalog (derived from each SKILL.md's frontmatter).
|
|
12
|
+
* @param {{ skillsDir?: string }} [opts]
|
|
13
|
+
* @returns {Array<{ name: string, description: string }>}
|
|
14
|
+
*/
|
|
15
|
+
export function listSkills({ skillsDir }?: {
|
|
16
|
+
skillsDir?: string;
|
|
17
|
+
}): Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
}>;
|
|
21
|
+
/**
|
|
22
|
+
* Copy named skills into <cwd>/.claude/skills/<name>/ (idempotent).
|
|
23
|
+
* @param {string[]} names
|
|
24
|
+
* @param {{ cwd?: string, force?: boolean, skillsDir?: string }} [opts]
|
|
25
|
+
* @returns {Array<{ name: string, status: 'added' | 'skipped' | 'unknown' }>}
|
|
26
|
+
*/
|
|
27
|
+
export function installSkills(names: string[], { cwd, force, skillsDir }?: {
|
|
28
|
+
cwd?: string;
|
|
29
|
+
force?: boolean;
|
|
30
|
+
skillsDir?: string;
|
|
31
|
+
}): Array<{
|
|
32
|
+
name: string;
|
|
33
|
+
status: "added" | "skipped" | "unknown";
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Print the catalog, marking already-installed skills.
|
|
37
|
+
* @param {{ skillsDir?: string, cwd?: string }} [opts]
|
|
38
|
+
*/
|
|
39
|
+
export function runSkillsList({ skillsDir, cwd }?: {
|
|
40
|
+
skillsDir?: string;
|
|
41
|
+
cwd?: string;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Resolve which skills to install, then copy and report.
|
|
45
|
+
* @param {string[]} names
|
|
46
|
+
* @param {{ all?: boolean, force?: boolean, skillsDir?: string, cwd?: string }} [opts]
|
|
47
|
+
*/
|
|
48
|
+
export function runSkillsAdd(names: string[], { all, force, skillsDir, cwd }?: {
|
|
49
|
+
all?: boolean;
|
|
50
|
+
force?: boolean;
|
|
51
|
+
skillsDir?: string;
|
|
52
|
+
cwd?: string;
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* CLI entry for `rokkit skills` subcommands.
|
|
56
|
+
* @param {'list' | 'add'} sub
|
|
57
|
+
* @param {{ _?: string[], all?: boolean, force?: boolean }} [opts]
|
|
58
|
+
*/
|
|
59
|
+
export function skillsCommand(sub: "list" | "add", opts?: {
|
|
60
|
+
_?: string[];
|
|
61
|
+
all?: boolean;
|
|
62
|
+
force?: boolean;
|
|
63
|
+
}): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokkit/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.13",
|
|
4
4
|
"description": "Command line utilities for icon bundling and conversion.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"files": [
|
|
37
37
|
"lib/**/*",
|
|
38
38
|
"src/**/*.js",
|
|
39
|
+
"skills/**",
|
|
39
40
|
"dist/**/*.d.ts",
|
|
40
41
|
"README.md",
|
|
41
42
|
"package.json",
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rokkit-components
|
|
3
|
+
description: Use when building UI with Rokkit components in a Svelte 5 app — the data-first contract (items, bindable value, fields field-mapping, onchange/onselect), snippet-based customization, built-in keyboard navigation + ARIA, and which component to reach for (List, Tree, Select, MultiSelect, Menu, Table, Tabs, Toggle).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Rokkit Components — Usage
|
|
7
|
+
|
|
8
|
+
Rokkit components are data-first: you pass an array, bind a value, and the component
|
|
9
|
+
handles rendering, keyboard navigation, and accessibility out of the box. You extend
|
|
10
|
+
behavior through the `fields` prop (remap data keys) and named snippets (customize
|
|
11
|
+
rendering) — not by modifying components or writing boilerplate.
|
|
12
|
+
|
|
13
|
+
Import from `@rokkit/ui`:
|
|
14
|
+
|
|
15
|
+
```svelte
|
|
16
|
+
import { List, Tree, Select, MultiSelect, Menu, Table, Tabs, Toggle } from '@rokkit/ui'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## The data-first contract
|
|
22
|
+
|
|
23
|
+
Every Rokkit component follows a consistent API:
|
|
24
|
+
|
|
25
|
+
| Prop | Purpose |
|
|
26
|
+
|------|---------|
|
|
27
|
+
| `items` / `options` / `data` | The data array to render |
|
|
28
|
+
| `bind:value` | Bindable selected value (or values array for MultiSelect) |
|
|
29
|
+
| `fields` | Remap your data's key names to component semantic keys |
|
|
30
|
+
| `onchange` / `onselect` | Callback fired when selection changes |
|
|
31
|
+
|
|
32
|
+
Primitive arrays (strings, numbers) work without any transformation:
|
|
33
|
+
|
|
34
|
+
```svelte
|
|
35
|
+
<script>
|
|
36
|
+
import { List } from '@rokkit/ui'
|
|
37
|
+
|
|
38
|
+
const nav = ['Dashboard', 'Reports', 'Settings']
|
|
39
|
+
let value = $state(null)
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<List items={nav} bind:value onselect={(v) => console.log('selected:', v)} />
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Object arrays are equally direct — use `fields` only when your key names differ from defaults:
|
|
46
|
+
|
|
47
|
+
```svelte
|
|
48
|
+
<script>
|
|
49
|
+
import { Select } from '@rokkit/ui'
|
|
50
|
+
|
|
51
|
+
const countries = [
|
|
52
|
+
{ code: 'us', name: 'United States' },
|
|
53
|
+
{ code: 'gb', name: 'United Kingdom' }
|
|
54
|
+
]
|
|
55
|
+
let value = $state(null)
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<!-- fields remaps 'name' → label slot, 'code' → value slot -->
|
|
59
|
+
<Select items={countries} fields={{ label: 'name', value: 'code' }} bind:value />
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Prop naming note:** `List`, `Tree`, `Select`, `MultiSelect`, and `Menu` all take `items`.
|
|
63
|
+
`Tabs` and `Toggle` take `options`. `Table` takes `data`.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Field mapping (fields)
|
|
68
|
+
|
|
69
|
+
The `fields` prop is a plain object that maps **semantic slot names** → **your data's key names**.
|
|
70
|
+
It never mutates your data — it is only a read instruction for the component.
|
|
71
|
+
|
|
72
|
+
### Default field keys
|
|
73
|
+
|
|
74
|
+
These are the raw key names each component reads by default. Override only the ones that differ in your data.
|
|
75
|
+
|
|
76
|
+
| Semantic slot | Default key | Used by |
|
|
77
|
+
|---------------|-------------|---------|
|
|
78
|
+
| `label` / `text` | `'label'` / `'text'` | display text (see note) |
|
|
79
|
+
| `value` | `'value'` | selection value |
|
|
80
|
+
| `icon` | `'icon'` | icon CSS class |
|
|
81
|
+
| `children` | `'children'` | nested items array |
|
|
82
|
+
| `href` | `'href'` | URL (renders as `<a>`) |
|
|
83
|
+
| `disabled` | `'disabled'` | disabled state |
|
|
84
|
+
| `expanded` | `'expanded'` | initial expand state |
|
|
85
|
+
| `type` | `'type'` | `'separator'` / `'spacer'` markers |
|
|
86
|
+
| `snippet` | `'snippet'` | per-item named snippet override |
|
|
87
|
+
| `badge` | `'badge'` | badge / count indicator |
|
|
88
|
+
| `description` | `'description'` | secondary subtitle text |
|
|
89
|
+
|
|
90
|
+
> **Text field note:** `List` / `Tree` / `Menu` read `item.label` by default.
|
|
91
|
+
> `Select` / `MultiSelect` also read `item.label` (they use `fields.label || 'label'`
|
|
92
|
+
> internally). Tabs reads `item.text` or `item.label`. `Toggle` reads `item.text`
|
|
93
|
+
> (with `label` as fallback). When in doubt, provide both keys or use `fields`.
|
|
94
|
+
|
|
95
|
+
### Remapping example
|
|
96
|
+
|
|
97
|
+
Your API returns `{ id, title, subitems }`. Map to Rokkit slots with `fields`:
|
|
98
|
+
|
|
99
|
+
```svelte
|
|
100
|
+
<List
|
|
101
|
+
items={apiData}
|
|
102
|
+
fields={{ value: 'id', label: 'title', children: 'subitems' }}
|
|
103
|
+
bind:value
|
|
104
|
+
/>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Table columns
|
|
108
|
+
|
|
109
|
+
`Table` uses a separate `columns` array to define column shapes. Row-level identity
|
|
110
|
+
is set via `fields={{ value: 'id' }}` (defaults to `'id'`):
|
|
111
|
+
|
|
112
|
+
```svelte
|
|
113
|
+
<script>
|
|
114
|
+
import { Table } from '@rokkit/ui'
|
|
115
|
+
|
|
116
|
+
const columns = [
|
|
117
|
+
{ name: 'name', label: 'Full Name', sortable: true },
|
|
118
|
+
{ name: 'role', label: 'Role', sortable: true },
|
|
119
|
+
{ name: 'active', label: 'Active', sortable: false, align: 'center' }
|
|
120
|
+
]
|
|
121
|
+
const rows = [
|
|
122
|
+
{ id: 1, name: 'Alice', role: 'Admin', active: true },
|
|
123
|
+
{ id: 2, name: 'Bob', role: 'Editor', active: false }
|
|
124
|
+
]
|
|
125
|
+
let value = $state(null)
|
|
126
|
+
</script>
|
|
127
|
+
|
|
128
|
+
<Table data={rows} {columns} bind:value />
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Snippet customization
|
|
134
|
+
|
|
135
|
+
Snippets let you replace inner rendering without changing the component's
|
|
136
|
+
wrapper, ARIA structure, or keyboard handling. The component always owns the
|
|
137
|
+
interactive container (`<button>`, `<a>`, tab panel, etc.) — snippets control
|
|
138
|
+
the **inner content** only.
|
|
139
|
+
|
|
140
|
+
### itemContent — List, Tree, Menu (and Toggle, for option labels)
|
|
141
|
+
|
|
142
|
+
The most common override. Receives a `ProxyItem` instance:
|
|
143
|
+
|
|
144
|
+
```svelte
|
|
145
|
+
<List {items} bind:value onselect={(v) => (value = v)}>
|
|
146
|
+
{#snippet itemContent(proxy)}
|
|
147
|
+
{#if proxy.icon}
|
|
148
|
+
<span class={proxy.icon} aria-hidden="true"></span>
|
|
149
|
+
{/if}
|
|
150
|
+
<span class="flex-1">{proxy.label}</span>
|
|
151
|
+
{#if proxy.get('badge')}
|
|
152
|
+
<span class="ml-auto text-xs text-ink-mute">{proxy.get('badge')}</span>
|
|
153
|
+
{/if}
|
|
154
|
+
{/snippet}
|
|
155
|
+
</List>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### groupContent — List, Menu (not Tree — tree nodes use itemContent)
|
|
159
|
+
|
|
160
|
+
Overrides the group header button content (the expand/collapse header row):
|
|
161
|
+
|
|
162
|
+
```svelte
|
|
163
|
+
<List {items} collapsible bind:value>
|
|
164
|
+
{#snippet groupContent(proxy)}
|
|
165
|
+
<span class={proxy.icon}></span>
|
|
166
|
+
<span>{proxy.label}</span>
|
|
167
|
+
<span class={proxy.expanded ? 'i-lucide:chevron-down' : 'i-lucide:chevron-right'}></span>
|
|
168
|
+
{/snippet}
|
|
169
|
+
</List>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### ProxyItem API (passed to all content snippets)
|
|
173
|
+
|
|
174
|
+
| Property / method | Description |
|
|
175
|
+
|-------------------|-------------|
|
|
176
|
+
| `proxy.label` | Mapped display text |
|
|
177
|
+
| `proxy.icon` | Mapped icon CSS class |
|
|
178
|
+
| `proxy.value` | The original raw item (object or primitive) |
|
|
179
|
+
| `proxy.href` | Mapped href |
|
|
180
|
+
| `proxy.disabled` | Whether this item is disabled |
|
|
181
|
+
| `proxy.expanded` | Expand state for groups/nodes (reactive `$state`) |
|
|
182
|
+
| `proxy.hasChildren` | True when the item has a `children` array |
|
|
183
|
+
| `proxy.get('key')` | Read any field by semantic or raw name |
|
|
184
|
+
|
|
185
|
+
### Select and MultiSelect snippets
|
|
186
|
+
|
|
187
|
+
Select and MultiSelect do not expose a trigger or chips snippet — the trigger face
|
|
188
|
+
and selected chips are rendered from `fields`/`label` and are not consumer-overridable.
|
|
189
|
+
|
|
190
|
+
Both components support `itemContent` and `groupContent` for the **dropdown options**,
|
|
191
|
+
using the same `ProxyItem` API as `List`:
|
|
192
|
+
|
|
193
|
+
```svelte
|
|
194
|
+
<Select {items} bind:value>
|
|
195
|
+
{#snippet itemContent(proxy)}
|
|
196
|
+
{#if proxy.icon}
|
|
197
|
+
<span class={proxy.icon} aria-hidden="true"></span>
|
|
198
|
+
{/if}
|
|
199
|
+
<span>{proxy.label}</span>
|
|
200
|
+
{#if proxy.get('description')}
|
|
201
|
+
<span class="text-xs text-ink-mute ml-auto">{proxy.get('description')}</span>
|
|
202
|
+
{/if}
|
|
203
|
+
{/snippet}
|
|
204
|
+
</Select>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The same `itemContent` / `groupContent` patterns work identically on `MultiSelect`.
|
|
208
|
+
|
|
209
|
+
### Tabs snippets
|
|
210
|
+
|
|
211
|
+
`Tabs` supports `tabPanel` (receives one `ProxyItem` argument) and `itemContent`
|
|
212
|
+
(for the tab trigger button content). Access panel data via `proxy.get('key')`:
|
|
213
|
+
|
|
214
|
+
```svelte
|
|
215
|
+
<Tabs {options} bind:value>
|
|
216
|
+
{#snippet tabPanel(proxy)}
|
|
217
|
+
<div class="p-4">
|
|
218
|
+
<h2 class="text-ink font-medium">{proxy.label}</h2>
|
|
219
|
+
<div>{proxy.get('content')}</div>
|
|
220
|
+
</div>
|
|
221
|
+
{/snippet}
|
|
222
|
+
</Tabs>
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`Tabs` also supports an `empty` snippet (no arguments) rendered when `options` is empty.
|
|
226
|
+
|
|
227
|
+
### Per-item named snippets (List)
|
|
228
|
+
|
|
229
|
+
Set `item.snippet = 'name'` on specific items to route them to a named snippet.
|
|
230
|
+
Other items fall back to `itemContent`:
|
|
231
|
+
|
|
232
|
+
```svelte
|
|
233
|
+
<script>
|
|
234
|
+
const items = [
|
|
235
|
+
{ label: 'Apple', snippet: 'fruit' },
|
|
236
|
+
{ label: 'Carrot', snippet: 'veggie' }
|
|
237
|
+
]
|
|
238
|
+
</script>
|
|
239
|
+
|
|
240
|
+
<List {items}>
|
|
241
|
+
{#snippet fruit(proxy)}
|
|
242
|
+
<span class="i-lucide:apple"></span>
|
|
243
|
+
<span>{proxy.label}</span>
|
|
244
|
+
{/snippet}
|
|
245
|
+
{#snippet veggie(proxy)}
|
|
246
|
+
<span class="i-lucide:carrot"></span>
|
|
247
|
+
<span>{proxy.label}</span>
|
|
248
|
+
{/snippet}
|
|
249
|
+
</List>
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Keyboard navigation & accessibility
|
|
255
|
+
|
|
256
|
+
Rokkit components are accessible out of the box — no extra wiring required as a consumer.
|
|
257
|
+
The navigator pattern handles keyboard events on the component's root or dropdown container.
|
|
258
|
+
|
|
259
|
+
### What you get for free
|
|
260
|
+
|
|
261
|
+
| Component | Keys | ARIA |
|
|
262
|
+
|-----------|------|------|
|
|
263
|
+
| List / Tree | `↑↓` move focus, `↵`/`Space` select, `→`/`←` expand/collapse groups, `Home`/`End`, typeahead `a-z` | `role="navigation"`, `aria-current="page"` on active link |
|
|
264
|
+
| Select / MultiSelect | `↑↓` navigate dropdown, `↵` select/toggle, `Esc` close, `Home`/`End` | `role="combobox"`, `aria-expanded`, `aria-selected` |
|
|
265
|
+
| Menu | `↑↓` navigate items, `↵`/`Space` select, `Esc` close + return focus | `role="menu"`, `role="menuitem"` |
|
|
266
|
+
| Tree | `↑↓` navigate, `→` expand/enter first child, `←` collapse/go to parent | `role="tree"`, `role="treeitem"`, `aria-expanded`, `aria-selected` |
|
|
267
|
+
| Table | `↑↓` between rows, `↵`/`Space` select | `role="grid"`, `aria-sort` on sortable columns, `aria-selected` on rows |
|
|
268
|
+
| Tabs | `←`/`→` (horizontal) or `↑`/`↓` (vertical) between tabs, `↵`/`Space` select | `role="tablist"`, `role="tab"`, `role="tabpanel"`, `aria-selected`, `aria-controls` |
|
|
269
|
+
| Toggle | `←`/`→` between options, `↵`/`Space` select | `role="radiogroup"`, `role="radio"`, `aria-checked` |
|
|
270
|
+
|
|
271
|
+
### Focus management note
|
|
272
|
+
|
|
273
|
+
The components sync `bind:value` to DOM focus automatically — when `value` changes
|
|
274
|
+
programmatically, the component moves keyboard focus to the matching item. You do not
|
|
275
|
+
need to call `focus()` yourself.
|
|
276
|
+
|
|
277
|
+
### Interactive elements inside snippets
|
|
278
|
+
|
|
279
|
+
If your `itemContent` snippet contains its own interactive elements (inputs, buttons),
|
|
280
|
+
stop propagation to prevent the navigator from also triggering selection:
|
|
281
|
+
|
|
282
|
+
```svelte
|
|
283
|
+
<List {items}>
|
|
284
|
+
{#snippet itemContent(proxy)}
|
|
285
|
+
<span class="flex-1">{proxy.label}</span>
|
|
286
|
+
<button
|
|
287
|
+
onclick={(e) => { e.stopPropagation(); doAction(proxy.value) }}
|
|
288
|
+
>
|
|
289
|
+
Action
|
|
290
|
+
</button>
|
|
291
|
+
{/snippet}
|
|
292
|
+
</List>
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Do NOT add an `onclick` on elements that also carry `data-path` — the navigator
|
|
296
|
+
intercepts those clicks and would double-fire.
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Which component when
|
|
301
|
+
|
|
302
|
+
| Need | Component | Data prop | Callback |
|
|
303
|
+
|------|-----------|-----------|----------|
|
|
304
|
+
| Vertical navigable list (sidebar nav, option list) | `List` | `items` | `onselect` |
|
|
305
|
+
| Hierarchical tree with expand/collapse | `Tree` | `items` | `onselect` |
|
|
306
|
+
| Dropdown single selection | `Select` | `items` | `onchange` |
|
|
307
|
+
| Dropdown multi-selection with chips | `MultiSelect` | `items` | `onchange` |
|
|
308
|
+
| Contextual action menu (dropdown actions) | `Menu` | `items` | `onselect` |
|
|
309
|
+
| Flat data table with columns + row selection | `Table` | `data` | `onselect` |
|
|
310
|
+
| Tabbed panels (horizontal or vertical) | `Tabs` | `options` | `onchange` |
|
|
311
|
+
| Compact mode/view switcher (segmented control) | `Toggle` | `options` | `onchange` |
|
|
312
|
+
|
|
313
|
+
**Use `List` not `Select`** when the options are always visible (sidebar, settings panel).
|
|
314
|
+
**Use `Select` not `List`** when screen space is limited and the dropdown can close.
|
|
315
|
+
**Use `Toggle` not `Tabs`** for 2–5 options with no panel content (just a mode switch).
|
|
316
|
+
**Use `Tree` not `List`** when data is genuinely hierarchical (file system, nested categories).
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Recipes
|
|
321
|
+
|
|
322
|
+
### Select bound to a value
|
|
323
|
+
|
|
324
|
+
```svelte
|
|
325
|
+
<script>
|
|
326
|
+
import { Select } from '@rokkit/ui'
|
|
327
|
+
|
|
328
|
+
const roles = [
|
|
329
|
+
{ label: 'Admin', value: 'admin' },
|
|
330
|
+
{ label: 'Editor', value: 'editor' },
|
|
331
|
+
{ label: 'Viewer', value: 'viewer' }
|
|
332
|
+
]
|
|
333
|
+
let role = $state('viewer')
|
|
334
|
+
</script>
|
|
335
|
+
|
|
336
|
+
<Select items={roles} bind:value={role} onchange={(v) => console.log('role:', v)} />
|
|
337
|
+
<p>Current role: {role}</p>
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Tree with nested children
|
|
341
|
+
|
|
342
|
+
```svelte
|
|
343
|
+
<script>
|
|
344
|
+
import { Tree } from '@rokkit/ui'
|
|
345
|
+
|
|
346
|
+
const files = [
|
|
347
|
+
{
|
|
348
|
+
label: 'src',
|
|
349
|
+
icon: 'i-lucide:folder',
|
|
350
|
+
children: [
|
|
351
|
+
{ label: 'index.ts', icon: 'i-lucide:file-code' },
|
|
352
|
+
{ label: 'utils.ts', icon: 'i-lucide:file-code' }
|
|
353
|
+
]
|
|
354
|
+
},
|
|
355
|
+
{ label: 'README.md', icon: 'i-lucide:file-text' }
|
|
356
|
+
]
|
|
357
|
+
|
|
358
|
+
let selected = $state(null)
|
|
359
|
+
</script>
|
|
360
|
+
|
|
361
|
+
<Tree items={files} bind:value={selected} onselect={(v) => console.log('file:', v)} />
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### MultiSelect with chips
|
|
365
|
+
|
|
366
|
+
```svelte
|
|
367
|
+
<script>
|
|
368
|
+
import { MultiSelect } from '@rokkit/ui'
|
|
369
|
+
|
|
370
|
+
const tags = [
|
|
371
|
+
{ label: 'Frontend', value: 'fe' },
|
|
372
|
+
{ label: 'Backend', value: 'be' },
|
|
373
|
+
{ label: 'DevOps', value: 'ops' }
|
|
374
|
+
]
|
|
375
|
+
let selected = $state([])
|
|
376
|
+
</script>
|
|
377
|
+
|
|
378
|
+
<MultiSelect
|
|
379
|
+
items={tags}
|
|
380
|
+
bind:value={selected}
|
|
381
|
+
placeholder="Select tags…"
|
|
382
|
+
onchange={(vals) => console.log('selected:', vals)}
|
|
383
|
+
/>
|
|
384
|
+
<p>Tags: {selected.join(', ')}</p>
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Table with explicit columns and field mapping
|
|
388
|
+
|
|
389
|
+
```svelte
|
|
390
|
+
<script>
|
|
391
|
+
import { Table } from '@rokkit/ui'
|
|
392
|
+
|
|
393
|
+
// Row identifier is 'uid' — remap via fields
|
|
394
|
+
const users = [
|
|
395
|
+
{ uid: 1, fullName: 'Alice Chen', dept: 'Engineering' },
|
|
396
|
+
{ uid: 2, fullName: 'Bob Tanaka', dept: 'Design' }
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
const columns = [
|
|
400
|
+
{ name: 'fullName', label: 'Name', sortable: true },
|
|
401
|
+
{ name: 'dept', label: 'Department', sortable: true }
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
let selectedUser = $state(null)
|
|
405
|
+
</script>
|
|
406
|
+
|
|
407
|
+
<Table
|
|
408
|
+
data={users}
|
|
409
|
+
{columns}
|
|
410
|
+
fields={{ value: 'uid' }}
|
|
411
|
+
bind:value={selectedUser}
|
|
412
|
+
caption="User list"
|
|
413
|
+
onselect={(v, row) => console.log('selected row:', row)}
|
|
414
|
+
/>
|
|
415
|
+
```
|
|
@@ -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
|
-
*
|
|
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
|
-
* @
|
|
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')
|
|
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}
|
|
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
|
+
}
|