@erclx/canon 4.8.1 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.8.1",
4
+ "version": "4.9.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -95,12 +95,22 @@ canon teach lesson <topic> --json \
95
95
  It writes nothing and reports four things:
96
96
 
97
97
  - `lesson`, the numbered path the lesson takes. Write it there.
98
- - `stylesheet` with `stylesheetExists` and `stylesheetHref`. Write the stylesheet only when it reports absent, which is the first lesson in the workspace. Every lesson after that links the file already on disk and adds to it rather than replacing it, since overwriting discards what the last lesson put there.
98
+ - `stylesheet` with `stylesheetExists` and `stylesheetHref`. Link it at `stylesheetHref` and seed it through the verb below when `stylesheetExists` is false, which is the first lesson in the workspace. Every lesson after that links the file already on disk and adds to it rather than replacing it, since overwriting discards what the last lesson put there.
99
99
  - `success`, the mission's success lines, carried here so Step 5 needs no second read.
100
100
  - `quiz`, one entry per question, carrying `order` and `answer`.
101
101
 
102
102
  Write the correct option first, then present the options in the order `order` reports, reading it as authored indices where `0` is the correct one. Take the order as given. Position drawn here rather than chosen is the whole reason the verb exists, and a lesson that reorders on its own judgment puts the answer back in the first slot.
103
103
 
104
+ Seed the stylesheet through the verb rather than authoring a palette, on the first lesson in a workspace:
105
+
106
+ ```bash
107
+ canon teach stylesheet <topic> --json
108
+ ```
109
+
110
+ It writes the design tokens as custom properties and the components built on them, from the one source every other rendered surface reads. Add lesson rules under the seed and reach a value through its property rather than restating the hex, which is what let each workspace fork the palette from every other. It refuses to overwrite, so running it again on a workspace that has grown its own rules is safe and reports `written` as false.
111
+
112
+ Report it rather than proceeding silently when the verb does not resolve, which is an installed CLI predating it. Do not fall back to writing a palette by hand.
113
+
104
114
  Add every term the lesson defines to `GLOSSARY.md` through the verb, which places the entries alphabetically in the shape the standard fixes:
105
115
 
106
116
  ```bash
@@ -22,6 +22,10 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
22
22
  | `canon indexes regen` | Regenerate `index.md` files from sibling frontmatter |
23
23
  | `canon docs [topic]` | Emit toolkit reference docs (`list`, or a topic by name) |
24
24
  | `canon design render` | Render `.claude/DESIGN.md` tokens to HTML and CSS |
25
+ | `canon design regen` | Rewrite this repository's `.claude/DESIGN.md` and `src/design/base.css` from `src/design/tokens.ts` |
26
+ | `canon design css` | Emit the design tokens and components as CSS on stdout (`--no-components` for properties alone) |
27
+ | `canon design install` | Install the base stylesheet into a project at `.claude/design/base.css` |
28
+ | `canon design sync` | Update a base stylesheet already installed under `.claude/design/` |
25
29
  | `canon slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck, reporting any unrecognized layout name on stderr |
26
30
  | `canon slides list` | List the available slide layouts (`--json` for the catalog) |
27
31
  | `canon feedback` | Write toolkit feedback from stdin to `.canon/review/feedback/`, or open a GitHub issue with `--github` |
@@ -36,6 +40,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
36
40
  | `canon teach open` | Open a workspace at the next ordinal with its mission, resources, and glossary files (`--json`) |
37
41
  | `canon teach resource` | Record sources and leads in a workspace, repeating `--read` or `--lead` as `<title>=<url>` (`--json`) |
38
42
  | `canon teach glossary` | Add terms to a workspace glossary alphabetically, repeating `--term <term>=<definition>` (`--json`) |
43
+ | `canon teach stylesheet` | Seed a workspace stylesheet from the design source, leaving an existing one alone without `--force` (`--json`) |
39
44
  | `canon records validate` | Report a session record or a standard against the standard governing it, per kind (`--json`) |
40
45
  | `canon records migrate` | Rewrite the records a validate finding names a recoverable transform for (`--write`, `--json`) |
41
46
  | `canon records size` | Report what each record folder holds and how much of it is recent, heaviest first (`--json`) |
@@ -112,11 +117,11 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
112
117
  | `demo` | `compile`, `run` |
113
118
  | `inventory` | `run` |
114
119
  | `wiki` | `init` |
115
- | `design` | `render` |
120
+ | `design` | `regen`, `css`, `render`, `install`, `sync` |
116
121
  | `slides` | `render`, `list` |
117
122
  | `tasks` | `archive`, `validate` |
118
123
  | `intake` | `list`, `answer` |
119
- | `teach` | `list`, `open`, `resource`, `glossary` |
124
+ | `teach` | `list`, `open`, `resource`, `glossary`, `lesson`, `stylesheet` |
120
125
  | `comments` | `scan` |
121
126
  | `context` | `audit` |
122
127
  | `markdown` | `audit` |
@@ -27,7 +27,29 @@ number bands behind that placement, where `900-999` is the range a
27
27
  project-authored rule takes and everything below it belongs to the toolkit.
28
28
 
29
29
  `canon sync --check` does not report an orphaned entry. It skips every one, so
30
- the destination reaches `canon gov sync` alone, the one domain sync verb left.
30
+ the destination reaches `canon gov sync` alone among the two per-file domain
31
+ syncs, since design's own orphans are the target's overrides and are meant to
32
+ stay where they are.
33
+
34
+ `canon design install` copies one toolkit-owned file to
35
+ `.claude/design/base.css` and creates no override. A project overrides a value
36
+ by writing `.claude/design/project/` itself, which `canon design sync` never
37
+ touches, because that subfolder is project-authored by location the way
38
+ `.claude/rules/project/` is. An override named exactly like the shipped file is
39
+ still the project's.
40
+
41
+ The override ships absent rather than empty. An empty file is one the project
42
+ did not ask for and did not write, the reconciliation already handles a missing
43
+ side, and an empty override invites a target to fill it before it has an
44
+ opinion.
45
+
46
+ Nothing arrives on a project that has not run `canon design install`. The
47
+ domain is detected by that folder existing, so `canon sync` skips it entirely on
48
+ a target that never installed it, and the unstamped line at the end of
49
+ `canon sync --check` stays quiet about it for the same reason. Governance is
50
+ named there when it is absent and design is not, because a managed project
51
+ without governance has yet to install what every project carries, while one
52
+ without design chose that.
31
53
 
32
54
  When the target's install recorded a stack, `canon gov sync` also reports a
33
55
  rule that stack lists and `.claude/rules/` does not hold, as a `missing` entry
@@ -125,6 +125,8 @@ It reports four things. `lesson` is the numbered path the lesson takes, derived
125
125
 
126
126
  The stylesheet is reported rather than written. A verb that wrote it on every lesson would discard whatever the last lesson added, and the second lesson in a workspace is the one that needs the file the first one left.
127
127
 
128
+ `canon teach stylesheet <topic>` is what writes it, seeded from the design source so a workspace renders in the system every other surface does. It refuses to overwrite, reporting `written` as false where the workspace already carries one, and `--force` takes the seed back over it. Each workspace used to carry a hand-authored palette, which is how the course styling forked once per workspace, so a lesson adds its own rules under the seed and reaches a value through its custom property rather than restating the hex.
129
+
128
130
  Each `quiz` entry carries `order`, the authored option indices in presentation order where index `0` is the correct answer, and `answer`, the one-based position that answer lands in. Both travel together because a caller deriving the position itself is a caller that can derive it wrongly.
129
131
 
130
132
  The order is drawn here rather than instructed, and that is the point of the verb. An author told to vary the position still varies it by judgment, and the judgment settles on the first slot, which is the defect this design departs from. The draw is uniform over the options, so the position carries no information about which answer is correct.
@@ -24,6 +24,8 @@ The toolkit seed in `tooling/claude/seeds/.claude/DESIGN.md` ships a token-table
24
24
 
25
25
  The `canon:claude-design-extract` skill drafts the file, sourcing tokens from a project's existing prose and CLI UI surfaces, or proposing them from `.claude/REQUIREMENTS.md` and a `## Personality` paragraph when no UI code exists yet. `canon design render` writes an HTML plus CSS preview to `.canon/review/design/` for eyeballing the current system without leaving Claude Code. See `.claude/context/design.md`.
26
26
 
27
+ A project wanting the toolkit's own values rather than its own runs `canon design install`, which copies one stylesheet to `.claude/design/base.css` carrying the token set as custom properties and two components built on them. That file is toolkit-owned and `canon design sync` refreshes it, so a project overrides a value in `.claude/design/project/` instead, which sync never touches. Nothing arrives without that install, and the two channels are independent: a record drafted by the extract skill is the project's own, and the installed stylesheet is the toolkit's.
28
+
27
29
  A cell no source anchors ends in `? verify`, and the preview shows that marker beside the value rather than folding it in, so a swatch and a font sample stay built from the value alone. A confidence line above the sections names how many cells are anchored against how many are tagged, which is what tells a reader whether they are looking at a record of the code or a proposal about it. It reads the columns a source could anchor and leaves out the row names, so the ratio is not diluted by cells no tag could ever reach. The proposal path tags nearly all of them, so that count reads low on day one by design.
28
30
 
29
31
  ### Tools
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.8.1",
4
+ "version": "4.9.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,14 +1,80 @@
1
- import { existsSync } from 'node:fs'
2
- import { resolve } from 'node:path'
1
+ import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
2
+ import { basename, join, relative, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
- import { creationRel } from '@/record-root'
4
+ import {
5
+ createDesignAdapter,
6
+ DESIGN_INSTALL_DIR,
7
+ DESIGN_PROJECT_SUBDIR,
8
+ } from '@/design/adapter'
9
+ import { buildDesignCss } from '@/design/css'
5
10
  import { renderDesignDoc } from '@/design/render'
6
- import { palette } from '@/ui'
11
+ import { DESIGN_BASE_CSS, DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
12
+ import { PROJECT_ROOT } from '@/project-root'
13
+ import { creationRel } from '@/record-root'
14
+ import { recordStamp, runDomainSync } from '@/sync/engine'
15
+ import { resolveTarget } from '@/target'
16
+ import { intro, logAdd, logError, logInfo, outro, palette } from '@/ui'
7
17
 
8
18
  export function register(program: Command): void {
9
19
  const design = program
10
20
  .command('design')
11
- .description('Design system commands (render)')
21
+ .description('Design system commands (regen, css, render, install, sync)')
22
+
23
+ design
24
+ .command('regen')
25
+ .description(
26
+ 'Rewrite .claude/DESIGN.md and the base stylesheet from src/design/tokens.ts',
27
+ )
28
+ .addHelpText(
29
+ 'after',
30
+ [
31
+ '',
32
+ 'Unlike install and sync, this runs against the toolkit checkout rather',
33
+ 'than a target. It rewrites the record and the base stylesheet from the',
34
+ 'token module and refuses where the record is absent, which is what the',
35
+ 'CLI installed into a project as a dependency looks like.',
36
+ '',
37
+ ].join('\n'),
38
+ )
39
+ .action(() => {
40
+ const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
41
+
42
+ // Both outputs resolve from `PROJECT_ROOT`, which is the installed
43
+ // package directory when the CLI runs out of a target's `node_modules`.
44
+ // The record is the one output that is already committed here and ships
45
+ // with no package, so its absence is what separates the two.
46
+ if (!existsSync(join(PROJECT_ROOT, DESIGN_DOCUMENT))) {
47
+ process.stderr.write(
48
+ `${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} No ${DESIGN_DOCUMENT} at ${PROJECT_ROOT}. Regen runs in the toolkit checkout, not against a target.\n${GREY}└${NC}\n`,
49
+ )
50
+ process.exitCode = 1
51
+ return
52
+ }
53
+
54
+ process.stderr.write(
55
+ `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Regenerate design source${NC}\n`,
56
+ )
57
+ const result = regenDesign(PROJECT_ROOT)
58
+ for (const path of [result.documentPath, result.cssPath]) {
59
+ process.stderr.write(
60
+ `${GREY}│${NC} ${GREEN}✓${NC} ${relative(PROJECT_ROOT, path)}\n`,
61
+ )
62
+ }
63
+ process.stderr.write(`${GREY}└${NC}\n`)
64
+ })
65
+
66
+ design
67
+ .command('css')
68
+ .description('Emit the design tokens and components as CSS on stdout')
69
+ .option(
70
+ '--no-components',
71
+ 'Custom properties only, without the component rules',
72
+ )
73
+ .action((opts: { components: boolean }) => {
74
+ process.stdout.write(
75
+ buildDesignCss(undefined, { components: opts.components }),
76
+ )
77
+ })
12
78
 
13
79
  design
14
80
  .command('render')
@@ -38,4 +104,80 @@ export function register(program: Command): void {
38
104
  `${GREY}│${NC} ${GREEN}✓${NC} ${result.htmlPath}\n${GREY}│${NC} ${GREEN}✓${NC} ${result.cssPath}\n${GREY}└${NC}\n`,
39
105
  )
40
106
  })
107
+
108
+ design
109
+ .command('install')
110
+ .description('Install the base stylesheet into a project')
111
+ .argument('[target]', 'Target directory', '.')
112
+ .helpOption('-h, --help', 'Show this help message')
113
+ .addHelpText(
114
+ 'after',
115
+ [
116
+ '',
117
+ 'Installs one toolkit-owned file and creates no override. A project',
118
+ `overrides a value by writing ${join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)}/`,
119
+ 'itself, which sync never touches. Nothing arrives on a project that',
120
+ 'has not run this, so a design value reaches a target on an install',
121
+ 'rather than on the next sync.',
122
+ '',
123
+ ].join('\n'),
124
+ )
125
+ .action(async (target: string) => {
126
+ process.exitCode = await runInstall(target)
127
+ })
128
+
129
+ design
130
+ .command('sync')
131
+ .description(
132
+ 'Update the base stylesheet already installed under .claude/design/',
133
+ )
134
+ .argument('[target]', 'Target directory', '.')
135
+ .helpOption('-h, --help', 'Show this help message')
136
+ .action(async (target: string) => {
137
+ process.exitCode = await runDomainSync(
138
+ createDesignAdapter(PROJECT_ROOT),
139
+ target,
140
+ { protectedRoot: PROJECT_ROOT },
141
+ )
142
+ })
143
+ }
144
+
145
+ /**
146
+ * Copies the base and stops. The override folder is deliberately not created:
147
+ * an empty override is a file the project did not ask for and did not write,
148
+ * the three-way merge already handles a missing side, and an empty file invites
149
+ * a target to fill it before it has an opinion.
150
+ */
151
+ async function runInstall(target: string): Promise<number> {
152
+ intro('canon design install')
153
+
154
+ const resolved = resolveTarget(target, PROJECT_ROOT)
155
+ if (typeof resolved === 'number') {
156
+ outro()
157
+ return resolved
158
+ }
159
+
160
+ const source = join(PROJECT_ROOT, DESIGN_BASE_CSS)
161
+ if (!existsSync(source)) {
162
+ logError(
163
+ `No base stylesheet at ${DESIGN_BASE_CSS}. Run canon design regen.`,
164
+ )
165
+ outro()
166
+ return 1
167
+ }
168
+
169
+ const dir = join(resolved, DESIGN_INSTALL_DIR)
170
+ mkdirSync(dir, { recursive: true })
171
+
172
+ const dest = join(dir, basename(source))
173
+ copyFileSync(source, dest)
174
+ logAdd(relative(resolved, dest))
175
+
176
+ await recordStamp(createDesignAdapter(PROJECT_ROOT), resolved, new Date())
177
+
178
+ logInfo(
179
+ `Override a value in ${join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)}/, which sync leaves alone.`,
180
+ )
181
+ outro()
182
+ return 0
41
183
  }
@@ -7,7 +7,7 @@ import {
7
7
  buildCheckReport,
8
8
  type CheckReport,
9
9
  hasDrift,
10
- SCANNED_DOMAINS,
10
+ uncoveredDomains,
11
11
  } from '@/sync/check'
12
12
  import { createGitRunner, createPullRequestOpener, hasGh } from '@/sync/git'
13
13
  import {
@@ -35,6 +35,7 @@ import { describeSkew } from '@/version/skew'
35
35
 
36
36
  const SYNC_ARGS: Record<SyncDomain, readonly string[]> = {
37
37
  governance: ['gov', 'sync'],
38
+ design: ['design', 'sync'],
38
39
  claude: ['claude', 'sync'],
39
40
  }
40
41
 
@@ -195,10 +196,7 @@ function renderCheck(report: CheckReport): void {
195
196
  // naming it here repeats what that section already said under a second
196
197
  // remedy, where a scanned domain nobody installed has no section at all and
197
198
  // this line is the only place it appears.
198
- const unmigrated = report.unmigrated.map((entry) => entry.domain)
199
- const uncovered = SCANNED_DOMAINS.filter(
200
- (domain) => !report.covers.includes(domain) && !unmigrated.includes(domain),
201
- )
199
+ const uncovered = uncoveredDomains(report)
202
200
  if (uncovered.length === 0) return
203
201
 
204
202
  const { GREY, NC } = palette(process.stderr)
@@ -16,6 +16,7 @@ import {
16
16
  type Term,
17
17
  type TermOutcome,
18
18
  type WorkspaceSummary,
19
+ writeStylesheet,
19
20
  } from '@/teach/workspace'
20
21
  import {
21
22
  intro,
@@ -276,6 +277,77 @@ export function register(program: Command): void {
276
277
  .action(async (topic: string, opts: LessonCommandOptions) => {
277
278
  process.exitCode = await runLesson(topic, opts)
278
279
  })
280
+
281
+ teach
282
+ .command('stylesheet')
283
+ .description('Seed a workspace stylesheet from the design source')
284
+ .argument('<topic>', 'Workspace folder or topic, as in regular-expressions')
285
+ .helpOption('-h, --help', 'Show this help message')
286
+ .option('--force', 'Rewrite a stylesheet the workspace already carries')
287
+ .option('--json', 'Emit a machine-readable record on stdout')
288
+ .option('--root <path>', 'Teach root, defaulting to the main worktree')
289
+ .addHelpText(
290
+ 'after',
291
+ [
292
+ '',
293
+ 'Exit codes:',
294
+ ' 0 the workspace carries a stylesheet, written now or already there',
295
+ ' 1 refused, with the reason on stderr or in the JSON record',
296
+ '',
297
+ 'The file it writes carries the design tokens as custom properties and',
298
+ 'the component rules built on them, so a workspace renders in the same',
299
+ 'system every other surface does. Add lesson rules under the seed and',
300
+ 'read a value through its property rather than restating the hex.',
301
+ '',
302
+ 'An existing stylesheet is left alone, since a workspace adds to this',
303
+ 'file as it goes. Pass --force to take the seed back over it.',
304
+ '',
305
+ 'Examples:',
306
+ ' canon teach stylesheet regular-expressions --json',
307
+ '',
308
+ ].join('\n'),
309
+ )
310
+ .action(async (topic: string, opts: StylesheetCommandOptions) => {
311
+ process.exitCode = await runStylesheet(topic, opts)
312
+ })
313
+ }
314
+
315
+ interface StylesheetCommandOptions {
316
+ readonly force?: boolean
317
+ readonly json?: boolean
318
+ readonly root?: string
319
+ }
320
+
321
+ async function runStylesheet(
322
+ topic: string,
323
+ opts: StylesheetCommandOptions,
324
+ ): Promise<number> {
325
+ const emitJson = opts.json ?? false
326
+ const root = await rootFor(opts.root)
327
+ const outcome = await writeStylesheet(root, topic, opts.force ?? false)
328
+
329
+ if (!outcome.ok) {
330
+ return reportRefusal('canon teach stylesheet', outcome, emitJson, root)
331
+ }
332
+
333
+ if (emitJson) {
334
+ process.stdout.write(
335
+ `${JSON.stringify({
336
+ ok: true,
337
+ root,
338
+ slug: outcome.slug,
339
+ path: outcome.path,
340
+ written: outcome.written,
341
+ })}\n`,
342
+ )
343
+ return 0
344
+ }
345
+
346
+ intro('canon teach stylesheet')
347
+ logStep(outcome.written ? 'Written' : 'Already present, left alone')
348
+ logInfo(outcome.path)
349
+ outro()
350
+ return 0
279
351
  }
280
352
 
281
353
  function collect(value: string, previous: string[]): string[] {
@@ -0,0 +1,59 @@
1
+ import { basename, join } from 'node:path'
2
+ import { DESIGN_BASE_CSS } from '@/design/regen'
3
+ import type { InstalledFile, SyncAdapter } from '@/sync/engine'
4
+
5
+ /**
6
+ * The channel a design value reaches a target on.
7
+ *
8
+ * Base plus a target-owned override, reconciled by the three-way merge the sync
9
+ * engine already implements. Overwrite was dropped before this was built and
10
+ * the reason is a measurement rather than an argument: a headless sync in
11
+ * another domain deleted a deploy job, replaced a screenshot harness, and
12
+ * reverted a shipped fix, because naming a stack was read as consent to lose an
13
+ * edit inside it.
14
+ *
15
+ * Layering is what bounds what the merge has to arbitrate. Nothing a project
16
+ * wrote lives in the base, so the base changes freely, and the merge shrinks to
17
+ * the properties a target actually overrode.
18
+ */
19
+
20
+ /** The folder a design install writes into, under the target's `.claude/`. */
21
+ export const DESIGN_INSTALL_DIR = join('.claude', 'design')
22
+
23
+ /**
24
+ * Where a target's own values go. `projectSubdir` makes every file here
25
+ * project-authored by location rather than by the name inference, so an
26
+ * override named `base.css` is still the target's and never overwritten.
27
+ *
28
+ * It ships absent rather than empty. An empty override is a file a project did
29
+ * not ask for and did not write, the merge already handles a missing side, and
30
+ * an empty file invites a target to fill it before it has an opinion.
31
+ */
32
+ export const DESIGN_PROJECT_SUBDIR = 'project'
33
+
34
+ export function designSourceDir(root: string): string {
35
+ return join(root, 'src', 'design')
36
+ }
37
+
38
+ export function createDesignAdapter(root: string): SyncAdapter {
39
+ const base = join(root, DESIGN_BASE_CSS)
40
+
41
+ return {
42
+ banner: 'canon design sync',
43
+ label: 'design',
44
+ missingMessage:
45
+ "No design surface found in target. Run 'canon design install' first.",
46
+ unit: 'changes',
47
+ installPattern: '**/*.css',
48
+ installedRoot: (target: string) => join(target, DESIGN_INSTALL_DIR),
49
+ /**
50
+ * One shipped file, matched by name the way the gov adapter matches a rule,
51
+ * so a base that moved inside the toolkit still syncs into the place the
52
+ * target already holds it.
53
+ */
54
+ locateSource: (file: InstalledFile) =>
55
+ basename(file.path) === basename(base) ? base : undefined,
56
+ projectSubdir: DESIGN_PROJECT_SUBDIR,
57
+ stamp: { domain: 'design', toolkitRoot: root },
58
+ }
59
+ }
@@ -0,0 +1,123 @@
1
+ /* Generated by `canon design regen` from src/design/tokens.ts. Do not edit.
2
+ This is the base half of a design install. A target overrides a value in
3
+ .claude/design/project/, which the three-way merge leaves alone, so this
4
+ file changes freely and nothing a project wrote lives in it. */
5
+
6
+ :root {
7
+ --color-background: #191512;
8
+ --color-surface: #211c19;
9
+ --color-border: #2f2823;
10
+ --color-text: #f4efe9;
11
+ --color-text-body: #c9c0b7;
12
+ --color-text-secondary: #a79d94;
13
+ --color-muted: #948a81;
14
+ --color-accent: #e0724b;
15
+ --color-light-background: #faf7f2;
16
+ --color-light-surface: #f4efe6;
17
+ --color-light-text: #1a1815;
18
+ --color-light-muted: #726b62;
19
+ --color-light-accent: #a4471c;
20
+ --color-light-border: #e4dcd0;
21
+ --space-xs: 6px;
22
+ --space-sm: 12px;
23
+ --space-md: 18px;
24
+ --space-lg: 24px;
25
+ --space-xl: 30px;
26
+ --space-frame-top: 44px;
27
+ --space-frame-inline: 52px;
28
+ --space-frame-bottom: 38px;
29
+ --type-display-size: 34px;
30
+ --type-display-lh: 1.3;
31
+ --type-heading-size: 19px;
32
+ --type-heading-lh: 1.3;
33
+ --type-body-size: 16px;
34
+ --type-body-lh: 1.65;
35
+ --type-label-size: 12px;
36
+ --type-label-lh: 1.45;
37
+ --type-code-size: 14.5px;
38
+ --type-code-lh: 1.3;
39
+ --radius-frame: 12px;
40
+ --radius-panel: 10px;
41
+ --radius-action: 7px;
42
+ --radius-pill: 999px;
43
+ --radius-marker: 999px;
44
+ }
45
+
46
+ /* The record declares no light counterpart for text-body, text-secondary, so
47
+ a light-ground surface using one is reading a dark value. Declare the
48
+ counterpart in src/design/tokens.ts rather than overriding it here. */
49
+ [data-theme='light'] {
50
+ --color-background: var(--color-light-background);
51
+ --color-surface: var(--color-light-surface);
52
+ --color-text: var(--color-light-text);
53
+ --color-muted: var(--color-light-muted);
54
+ --color-accent: var(--color-light-accent);
55
+ --color-border: var(--color-light-border);
56
+ }
57
+
58
+ /* status
59
+ A dot and a word, not a pill. A bordered uppercase chip is a second
60
+ vocabulary for a fact the dot already carries, so the marker is the only
61
+ status shape and `.is-done` is the only variant. */
62
+
63
+ .status {
64
+ display: inline-flex;
65
+ align-items: center;
66
+ gap: 0.42rem;
67
+ font-size: 0.75rem;
68
+ font-weight: 500;
69
+ letter-spacing: 0;
70
+ text-transform: none;
71
+ color: var(--color-muted);
72
+ white-space: nowrap;
73
+ }
74
+
75
+ .status::before {
76
+ content: '';
77
+ width: 6px;
78
+ height: 6px;
79
+ flex: none;
80
+ border-radius: var(--radius-marker);
81
+ background: var(--color-border);
82
+ }
83
+
84
+ .status.is-done::before {
85
+ background: var(--color-accent);
86
+ }
87
+
88
+ /* scrollbar
89
+ Every scrolling region takes the same bar, the page included. Scoping it
90
+ to one component leaves its neighbors on the browser default, which is
91
+ what reads as two designs on one page. `scrollbar-color` covers Firefox
92
+ and the `::-webkit-` rules cover the rest, both from the same two tokens
93
+ so the two engines cannot drift apart. */
94
+
95
+ * {
96
+ scrollbar-width: thin;
97
+ scrollbar-color: var(--color-border) transparent;
98
+ }
99
+
100
+ *::-webkit-scrollbar {
101
+ width: 10px;
102
+ height: 10px;
103
+ }
104
+
105
+ *::-webkit-scrollbar-track {
106
+ background: transparent;
107
+ }
108
+
109
+ *::-webkit-scrollbar-thumb {
110
+ border-radius: var(--radius-marker);
111
+ background: var(--color-border);
112
+ /* Inset by painting a border in the page color, which is what keeps the
113
+ thumb off the edges without a second element. */
114
+ border: 3px solid var(--color-background);
115
+ }
116
+
117
+ *::-webkit-scrollbar-thumb:hover {
118
+ background: var(--color-muted);
119
+ }
120
+
121
+ *::-webkit-scrollbar-corner {
122
+ background: transparent;
123
+ }