@erclx/aitk 1.3.0 → 1.4.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": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "1.3.0",
4
+ "version": "1.4.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -46,6 +46,19 @@ Help skips the banner. The `Usage:` line sits directly on `├`. Help writes to
46
46
 
47
47
  `--json` and `--names` keep stdout clean and machine-readable. The frame still renders on stderr (open, banner, close) so the stream discipline is consistent across modes. Consumers that only read stdout see pure data.
48
48
 
49
+ ## Color
50
+
51
+ Escape sequences reach a destination that renders them and nowhere else. The question is asked per stream rather than once for the process, so a run piping its data while keeping a terminal on stderr still gets color on the frame.
52
+
53
+ Either condition alone turns color off:
54
+
55
+ - `NO_COLOR` is set to any non-empty value, whatever that value says
56
+ - The destination is not a terminal, which covers a pipe, a file, and a captured session
57
+
58
+ The frame survives both. `┌`, `│`, `├`, `└`, and the `✓ ! + - ✗` marks are structure rather than color, and they are what lets a captured run still read as one block. A caller wanting neither the frame nor the color reads `--json` instead.
59
+
60
+ Terminal control is a separate question this section does not cover. The cursor and key sequences an interactive prompt writes run only where a terminal already exists.
61
+
49
62
  ## Process exit
50
63
 
51
64
  A command action sets `process.exitCode` and returns. Calling `process.exit()` there ends the process before a stdout write drains, which truncates piped output at the 64K pipe buffer while still reporting the right exit code. Redirecting to a file hides the truncation, so it surfaces only through a pipe, which is what a check has to use to catch it.
@@ -24,6 +24,8 @@ The toolkit seed in `tooling/claude/seeds/.claude/DESIGN.md` ships a token-table
24
24
 
25
25
  The `aitk: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. `aitk design render` writes an HTML plus CSS preview to `.claude/review/design/` for eyeballing the current system without leaving Claude Code. See `.claude/context/design.md`.
26
26
 
27
+ 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
+
27
29
  ### Tools
28
30
 
29
31
  - None beyond Claude Code itself
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "1.3.0",
4
+ "version": "1.4.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
package/src/cli.ts CHANGED
@@ -28,12 +28,12 @@ import { register as sessions } from '@/commands/sessions'
28
28
  import { register as audits } from '@/commands/audits'
29
29
  import { register as upgrade } from '@/commands/upgrade'
30
30
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
31
-
32
- const GREY = '\x1b[0;90m'
33
- const WHITE = '\x1b[1;37m'
34
- const NC = '\x1b[0m'
31
+ import { palette } from '@/ui'
35
32
 
36
33
  function showHelp(): void {
34
+ // The help text is the one framed surface written to stdout, so it asks
35
+ // about that stream rather than the stderr every other writer here uses.
36
+ const { GREY, NC, WHITE } = palette(process.stdout)
37
37
  const lines = [
38
38
  `${GREY}┌${NC}`,
39
39
  `${GREY}├${NC} ${WHITE}Usage:${NC} aitk [command]`,
@@ -44,16 +44,13 @@ import {
44
44
  logStep,
45
45
  logWarn,
46
46
  outro,
47
+ palette,
47
48
  pipeOutput,
48
49
  plural,
49
50
  select,
50
51
  } from '@/ui'
51
52
  import { describeSkew, readSkew, type SkewReport } from '@/version/skew'
52
53
 
53
- const GREEN = '\x1b[0;32m'
54
- const GREY = '\x1b[0;90m'
55
- const NC = '\x1b[0m'
56
-
57
54
  interface SeedsListOptions {
58
55
  readonly json?: boolean
59
56
  readonly names?: boolean
@@ -266,6 +263,7 @@ export function register(program: Command): void {
266
263
  }
267
264
 
268
265
  function succeed(message: string): number {
266
+ const { GREEN, NC } = palette(process.stderr)
269
267
  outro()
270
268
  process.stderr.write(`${GREEN}✓ ${message}${NC}\n`)
271
269
  return 0
@@ -472,6 +470,7 @@ async function runSeedsList(opts: SeedsListOptions): Promise<number> {
472
470
  return 0
473
471
  }
474
472
 
473
+ const { GREY, NC } = palette(process.stderr)
475
474
  intro('aitk claude')
476
475
  logStep('Seed docs')
477
476
  for (const listing of listings) {
@@ -531,6 +530,7 @@ async function runSkillsDrift(
531
530
  // is the moment a skew warning is worth most, since an old binary is one
532
531
  // reason the cache and the CLI disagree in the first place.
533
532
  if (skew.state === 'behind') {
533
+ const { GREY, NC } = palette(process.stderr)
534
534
  process.stderr.write(`${GREY}${describeSkew(skew)}${NC}\n`)
535
535
  }
536
536
  }
@@ -2,12 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { renderDesignDoc } from '@/design/render'
5
-
6
- const GREY = '\x1b[0;90m'
7
- const WHITE = '\x1b[1;37m'
8
- const RED = '\x1b[0;31m'
9
- const GREEN = '\x1b[0;32m'
10
- const NC = '\x1b[0m'
5
+ import { palette } from '@/ui'
11
6
 
12
7
  export function register(program: Command): void {
13
8
  const design = program
@@ -22,6 +17,7 @@ export function register(program: Command): void {
22
17
  .action((opts: { source: string; out: string }) => {
23
18
  const sourcePath = resolve(process.cwd(), opts.source)
24
19
  const outDir = resolve(process.cwd(), opts.out)
20
+ const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
25
21
  if (!existsSync(sourcePath)) {
26
22
  process.stderr.write(
27
23
  `${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} ${opts.source} not found\n${GREY}└${NC}\n`,
@@ -4,10 +4,7 @@ import type { Command } from 'commander'
4
4
  import { deriveSlug, deriveTitle } from '@/commands/feedback-format'
5
5
  import { PROJECT_ROOT } from '@/project-root'
6
6
  import { createGithubIssue } from '@/github'
7
- import { frameError, frameSuccess } from '@/ui'
8
-
9
- const YELLOW = '\x1b[0;33m'
10
- const NC = '\x1b[0m'
7
+ import { frameError, frameSuccess, palette } from '@/ui'
11
8
 
12
9
  function readStdin(): Promise<string> {
13
10
  return new Promise((resolveStream, rejectStream) => {
@@ -88,6 +85,7 @@ export function register(program: Command): void {
88
85
  process.exitCode = 1
89
86
  return
90
87
  }
88
+ const { NC, YELLOW } = palette(process.stderr)
91
89
  process.stderr.write(
92
90
  `${YELLOW}! gh unavailable, wrote local scratch instead${NC}\n`,
93
91
  )
@@ -30,12 +30,10 @@ import {
30
30
  logStep,
31
31
  logWarn,
32
32
  outro,
33
+ palette,
33
34
  select,
34
35
  } from '@/ui'
35
36
 
36
- const GREEN = '\x1b[0;32m'
37
- const NC = '\x1b[0m'
38
-
39
37
  const PAYLOAD_REL = join('.claude', '.tmp', 'gov', 'rules.md')
40
38
  const RULES_REL = join('.claude', 'rules')
41
39
 
@@ -461,6 +459,7 @@ async function runInstall(
461
459
  )
462
460
  }
463
461
 
462
+ const { GREEN, NC } = palette(process.stderr)
464
463
  outro()
465
464
  process.stderr.write(`${GREEN}✓ Rules installed${NC}\n`)
466
465
  return 0
@@ -510,6 +509,7 @@ async function runBuild(target: string): Promise<number> {
510
509
  await writeFile(output, buildRulesPayload(files))
511
510
  logAdd(PAYLOAD_REL)
512
511
 
512
+ const { GREEN, NC } = palette(process.stderr)
513
513
  outro()
514
514
  process.stderr.write(
515
515
  `${GREEN}✓ Rules built (${files.length} rules → ${PAYLOAD_REL})${NC}\n`,
@@ -6,11 +6,7 @@ import { type InitFlags, parseSkip, planInit } from '@/init/plan'
6
6
  import { runDomains } from '@/init/run'
7
7
  import { buildSteps } from '@/init/steps'
8
8
  import { resolveTarget } from '@/target'
9
- import { intro, logInfo, logStep, logWarn, outro, select } from '@/ui'
10
-
11
- const GREEN = '\x1b[0;32m'
12
- const YELLOW = '\x1b[0;33m'
13
- const NC = '\x1b[0m'
9
+ import { intro, logInfo, logStep, logWarn, outro, palette, select } from '@/ui'
14
10
 
15
11
  interface InitOptions {
16
12
  /** Always present: the option falls back to `DEFAULT_STACK`. */
@@ -107,6 +103,8 @@ async function runInit(
107
103
  outro()
108
104
  process.stderr.write('\n')
109
105
 
106
+ const { GREEN, NC, YELLOW } = palette(process.stderr)
107
+
110
108
  if (failed.length === 0) {
111
109
  process.stderr.write(
112
110
  `${GREEN}✓ Project initialized (${plan.total} domains)${NC}\n`,
@@ -5,12 +5,7 @@ import { LAYOUTS } from '@/slides/layouts'
5
5
  import { openDeck } from '@/slides/open'
6
6
  import { renderSlidesDoc } from '@/slides/render'
7
7
  import type { Variant } from '@/slides/styles'
8
- import { intro, outro } from '@/ui'
9
-
10
- const GREY = '\x1b[0;90m'
11
- const GREEN = '\x1b[0;32m'
12
- const RED = '\x1b[0;31m'
13
- const NC = '\x1b[0m'
8
+ import { intro, outro, palette } from '@/ui'
14
9
 
15
10
  export function register(program: Command): void {
16
11
  const slides = program
@@ -44,6 +39,7 @@ export function register(program: Command): void {
44
39
  }
45
40
  const variant = parseVariant(opts.variant)
46
41
  const mirror = resolveMirror(opts.mirror)
42
+ const { GREEN, GREY, NC, RED } = palette(process.stderr)
47
43
  intro('Render slides')
48
44
  const result = await renderSlidesDoc(sourcePath, outDir, {
49
45
  variant,
@@ -81,6 +77,7 @@ export function register(program: Command): void {
81
77
  process.stdout.write(`${JSON.stringify(LAYOUTS)}\n`)
82
78
  return
83
79
  }
80
+ const { GREEN, GREY, NC } = palette(process.stderr)
84
81
  intro('Slide layouts')
85
82
  for (const layout of LAYOUTS) {
86
83
  process.stderr.write(
@@ -121,6 +118,7 @@ function fail(message: string): never {
121
118
  */
122
119
  function reportFailure(error: unknown): void {
123
120
  if (!(error instanceof SlidesError)) throw error
121
+ const { GREY, NC, RED } = palette(process.stderr)
124
122
  process.stderr.write(
125
123
  `${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} ${error.message}\n${GREY}└${NC}\n`,
126
124
  )
@@ -21,12 +21,10 @@ import {
21
21
  logStep,
22
22
  logWarn,
23
23
  outro,
24
+ palette,
24
25
  select,
25
26
  } from '@/ui'
26
27
 
27
- const GREEN = '\x1b[0;32m'
28
- const NC = '\x1b[0m'
29
-
30
28
  const SNIPPETS_REL = '.claude/snippets'
31
29
 
32
30
  const PASS_THROUGH_VERBS = ['create'] as const
@@ -180,6 +178,7 @@ async function runInstall(
180
178
  }
181
179
  await recordStamp(createSnippetsAdapter(PROJECT_ROOT), resolved, new Date())
182
180
 
181
+ const { GREEN, NC } = palette(process.stderr)
183
182
  outro()
184
183
  process.stderr.write(`${GREEN}✓ Snippets installed${NC}\n`)
185
184
  return 0
@@ -21,13 +21,10 @@ import {
21
21
  logStep,
22
22
  logWarn,
23
23
  outro,
24
+ palette,
24
25
  select,
25
26
  } from '@/ui'
26
27
 
27
- const GREEN = '\x1b[0;32m'
28
- const GREY = '\x1b[0;90m'
29
- const NC = '\x1b[0m'
30
-
31
28
  interface InstallOptions {
32
29
  /** Always present: the option falls back to `ALL_SELECTION`. */
33
30
  readonly only: string
@@ -196,6 +193,7 @@ async function runInstall(target: string, selection: string): Promise<number> {
196
193
  await recordStamp(createStandardsAdapter(PROJECT_ROOT), resolved, new Date())
197
194
 
198
195
  outro()
196
+ const { GREEN, GREY, NC } = palette(process.stderr)
199
197
  process.stderr.write(
200
198
  `\n${GREEN}✓ Standards installed${NC} ${GREY}(${files.length} files)${NC}\n`,
201
199
  )
@@ -29,13 +29,10 @@ import {
29
29
  logStep,
30
30
  logWarn,
31
31
  outro,
32
+ palette,
32
33
  } from '@/ui'
33
34
  import { describeSkew } from '@/version/skew'
34
35
 
35
- const GREY = '\x1b[0;90m'
36
- const YELLOW = '\x1b[0;33m'
37
- const NC = '\x1b[0m'
38
-
39
36
  const SYNC_ARGS: Record<SyncDomain, readonly string[]> = {
40
37
  standards: ['standards', 'sync'],
41
38
  snippets: ['snippets', 'sync'],
@@ -180,6 +177,7 @@ function renderCheck(report: CheckReport): void {
180
177
  )
181
178
  if (uncovered.length === 0) return
182
179
 
180
+ const { GREY, NC } = palette(process.stderr)
183
181
  process.stderr.write(
184
182
  `${GREY}Unstamped: ${uncovered.join(', ')}. Run the matching sync to record one.${NC}\n`,
185
183
  )
@@ -338,6 +336,7 @@ async function runSync(target: string): Promise<number> {
338
336
  if (typeof resolved === 'number') return resolved
339
337
 
340
338
  const git = createGitRunner(resolved)
339
+ const { GREY, NC, YELLOW } = palette(process.stderr)
341
340
 
342
341
  logStep('Checking working tree')
343
342
  if (!isTreeClean(await git.status([]))) {
@@ -28,12 +28,10 @@ import {
28
28
  logStep,
29
29
  logWarn,
30
30
  outro,
31
+ palette,
31
32
  select,
32
33
  } from '@/ui'
33
34
 
34
- const GREEN = '\x1b[0;32m'
35
- const NC = '\x1b[0m'
36
-
37
35
  const PASS_THROUGH_VERBS = ['ref', 'create', 'verify'] as const
38
36
 
39
37
  interface SyncOptions {
@@ -256,6 +254,7 @@ async function runSync(
256
254
  report(result, includeReferences)
257
255
 
258
256
  const mode = resolveWriteMode(opts)
257
+ const { GREEN, NC } = palette(process.stderr)
259
258
 
260
259
  if (result.totalChanges === 0) {
261
260
  // The stamp is a write like any other, so a run with no authority to write
@@ -1,12 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { ensureYtDlp, fetchOne } from '@/transcripts/fetch'
4
-
5
- const GREY = '\x1b[0;90m'
6
- const WHITE = '\x1b[1;37m'
7
- const RED = '\x1b[0;31m'
8
- const GREEN = '\x1b[0;32m'
9
- const NC = '\x1b[0m'
4
+ import { palette } from '@/ui'
10
5
 
11
6
  interface TranscriptOptions {
12
7
  out: string
@@ -24,6 +19,7 @@ export function register(program: Command): void {
24
19
  )
25
20
  .action(async (url: string, opts: TranscriptOptions) => {
26
21
  const outDir = resolve(process.cwd(), opts.out)
22
+ const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
27
23
  process.stderr.write(
28
24
  `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}aitk transcripts${NC}\n`,
29
25
  )
@@ -9,6 +9,7 @@ import {
9
9
  logStep,
10
10
  logWarn,
11
11
  outro,
12
+ palette,
12
13
  select,
13
14
  } from '@/ui'
14
15
  import {
@@ -20,9 +21,6 @@ import {
20
21
  WIKI_INDEX_REL,
21
22
  } from '@/wiki/init'
22
23
 
23
- const GREEN = '\x1b[0;32m'
24
- const NC = '\x1b[0m'
25
-
26
24
  export function register(program: Command): void {
27
25
  const wiki = program
28
26
  .command('wiki')
@@ -59,6 +57,7 @@ async function runInit(target: string): Promise<number> {
59
57
  }
60
58
 
61
59
  const plan = planWikiInit(resolved)
60
+ const { GREEN, NC } = palette(process.stderr)
62
61
 
63
62
  logStep(`Scanning ${WIKI_DIR_REL}`)
64
63
  if (plan.changes.includes('dir')) logAdd(WIKI_DIR_REL)
@@ -1,6 +1,19 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
 
3
- export type Row = Record<string, string>
3
+ /**
4
+ * One table cell, split into the value a renderer emits and whether the record
5
+ * marked it as unsourced.
6
+ *
7
+ * The marker sits inside the cell rather than in a trailing column, because a
8
+ * trailing marker breaks the table parse. Splitting it out here is what keeps a
9
+ * swatch or a font sample built from the value alone.
10
+ */
11
+ export interface Cell {
12
+ value: string
13
+ tagged: boolean
14
+ }
15
+
16
+ export type Row = Record<string, Cell>
4
17
 
5
18
  export interface DesignDoc {
6
19
  personality: string
@@ -12,6 +25,11 @@ export interface DesignDoc {
12
25
  iconography: string
13
26
  }
14
27
 
28
+ const VERIFY_TAG = /\s*\?\s*verify\s*$/
29
+
30
+ /** A cell whose whole content is one balanced code span and nothing else. */
31
+ const CODE_SPAN = /^`([^`]*)`$/
32
+
15
33
  export function parseDesignDoc(path: string): DesignDoc {
16
34
  const raw = readFileSync(path, 'utf8')
17
35
  const sections = splitSections(raw)
@@ -58,22 +76,41 @@ function table(body: string | undefined): Row[] {
58
76
  if (!body) return []
59
77
  const rows = body.split('\n').filter((l) => l.trim().startsWith('|'))
60
78
  if (rows.length < 2) return []
61
- const headers = splitRow(rows[0])
79
+ const headers = splitRow(rows[0]).map((c) => c.value)
62
80
  const data = rows.slice(2)
63
81
  return data.map((line) => {
64
82
  const cells = splitRow(line)
65
83
  const row: Row = {}
66
84
  headers.forEach((h, i) => {
67
- row[h] = (cells[i] ?? '').trim()
85
+ row[h] = cells[i] ?? emptyCell()
68
86
  })
69
87
  return row
70
88
  })
71
89
  }
72
90
 
73
- function splitRow(line: string): string[] {
91
+ function emptyCell(): Cell {
92
+ return { value: '', tagged: false }
93
+ }
94
+
95
+ function splitRow(line: string): Cell[] {
74
96
  return line
75
97
  .replace(/^\s*\|/, '')
76
98
  .replace(/\|\s*$/, '')
77
99
  .split('|')
78
- .map((c) => c.trim().replace(/\s*\?\s*verify\s*$/, ''))
100
+ .map(parseCell)
101
+ }
102
+
103
+ /**
104
+ * The tag is tested against the cell with any surrounding code span removed,
105
+ * since a value wrapping itself in backticks puts one after the tag and an
106
+ * end-anchored test misses it there. The span is restored around the clean
107
+ * value so an untagged cell and a tagged one carry the same formatting.
108
+ */
109
+ function parseCell(raw: string): Cell {
110
+ const trimmed = raw.trim()
111
+ const span = trimmed.match(CODE_SPAN)
112
+ const inner = span ? span[1] : trimmed
113
+ if (!VERIFY_TAG.test(inner)) return { value: trimmed, tagged: false }
114
+ const value = inner.replace(VERIFY_TAG, '')
115
+ return { value: span ? `\`${value}\`` : value, tagged: true }
79
116
  }
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import type { DesignDoc, Row } from '@/design/parse'
3
+ import type { Cell, DesignDoc, Row } from '@/design/parse'
4
4
  import { parseDesignDoc } from '@/design/parse'
5
5
 
6
6
  export interface RenderResult {
@@ -28,29 +28,99 @@ function slug(s: string): string {
28
28
  .replace(/(^-|-$)/g, '')
29
29
  }
30
30
 
31
+ function cell(row: Row, key: string): Cell {
32
+ return row[key] ?? { value: '', tagged: false }
33
+ }
34
+
35
+ /** The value alone. Every swatch, sample, and custom property is built from it. */
36
+ function val(row: Row, key: string): string {
37
+ return cell(row, key).value
38
+ }
39
+
40
+ /** The marker, rendered beside a value rather than inside it. */
41
+ function mark(row: Row, key: string): string {
42
+ return cell(row, key).tagged
43
+ ? ' <span class="verify" title="No source anchors this value">? verify</span>'
44
+ : ''
45
+ }
46
+
47
+ /** A displayed cell: its escaped text, then its marker when it carries one. */
48
+ function cellText(row: Row, key: string): string {
49
+ return escape(val(row, key)) + mark(row, key)
50
+ }
51
+
52
+ interface Confidence {
53
+ tagged: number
54
+ total: number
55
+ }
56
+
57
+ /**
58
+ * The columns the confidence ratio reads, fixed by `standards/design.md`. The
59
+ * first column of each table names its row, and `Multiplier` and `When used`
60
+ * restate what the row already carries, so none of them is something a source
61
+ * could anchor and none belongs in the denominator.
62
+ */
63
+ const ANCHORABLE = {
64
+ borders: ['Radius', 'Width'],
65
+ color: ['Intent', 'Value'],
66
+ spacing: ['Value'],
67
+ typography: ['Family', 'Weight', 'Size', 'Line height'],
68
+ } as const
69
+
70
+ /**
71
+ * A cell counts when it carries a tag, or when it holds a value in a column a
72
+ * source could anchor. A blank the record left unfilled is neither, and so is a
73
+ * row name. Counting a tagged cell whichever column it sits in is what keeps a
74
+ * marker the preview draws from sitting outside the ratio printed beside it.
75
+ */
76
+ function confidence(doc: DesignDoc): Confidence {
77
+ const tables: ReadonlyArray<readonly [Row[], readonly string[]]> = [
78
+ [doc.color, ANCHORABLE.color],
79
+ [doc.typography, ANCHORABLE.typography],
80
+ [doc.spacing, ANCHORABLE.spacing],
81
+ [doc.borders, ANCHORABLE.borders],
82
+ ]
83
+ let tagged = 0
84
+ let total = 0
85
+ for (const [rows, columns] of tables) {
86
+ for (const row of rows) {
87
+ for (const [key, c] of Object.entries(row)) {
88
+ if (!c.tagged && (!c.value || !columns.includes(key))) continue
89
+ total += 1
90
+ if (c.tagged) tagged += 1
91
+ }
92
+ }
93
+ }
94
+ return { tagged, total }
95
+ }
96
+
31
97
  function buildCss(doc: DesignDoc): string {
32
98
  const lines: string[] = [':root {']
33
99
  for (const row of doc.color) {
34
- if (row['Value']) {
35
- lines.push(` --color-${slug(row['Role'])}: ${row['Value']};`)
100
+ if (val(row, 'Value')) {
101
+ lines.push(` --color-${slug(val(row, 'Role'))}: ${val(row, 'Value')};`)
36
102
  }
37
103
  }
38
104
  for (const row of doc.spacing) {
39
- if (row['Value']) {
40
- lines.push(` --space-${slug(row['Step'])}: ${row['Value']};`)
105
+ if (val(row, 'Value')) {
106
+ lines.push(` --space-${slug(val(row, 'Step'))}: ${val(row, 'Value')};`)
41
107
  }
42
108
  }
43
109
  for (const row of doc.typography) {
44
- if (row['Size']) {
45
- lines.push(` --type-${slug(row['Role'])}-size: ${row['Size']};`)
110
+ if (val(row, 'Size')) {
111
+ lines.push(
112
+ ` --type-${slug(val(row, 'Role'))}-size: ${val(row, 'Size')};`,
113
+ )
46
114
  }
47
- if (row['Line height']) {
48
- lines.push(` --type-${slug(row['Role'])}-lh: ${row['Line height']};`)
115
+ if (val(row, 'Line height')) {
116
+ lines.push(
117
+ ` --type-${slug(val(row, 'Role'))}-lh: ${val(row, 'Line height')};`,
118
+ )
49
119
  }
50
120
  }
51
121
  for (const row of doc.borders) {
52
- if (row['Radius']) {
53
- lines.push(` --radius-${slug(row['Role'])}: ${row['Radius']};`)
122
+ if (val(row, 'Radius')) {
123
+ lines.push(` --radius-${slug(val(row, 'Role'))}: ${val(row, 'Radius')};`)
54
124
  }
55
125
  }
56
126
  lines.push('}')
@@ -75,6 +145,14 @@ function buildHtml(doc: DesignDoc): string {
75
145
  sectionLine('Motion', doc.motion),
76
146
  sectionLine('Iconography', doc.iconography),
77
147
  ]
148
+ const { tagged, total } = confidence(doc)
149
+ const verifyStyle = tagged
150
+ ? '\n .verify { color: #a4471c; font-size: 12px; font-weight: 600; margin-left: 0.35rem; white-space: nowrap; }'
151
+ : ''
152
+ const verb = tagged === 1 ? 'carries' : 'carry'
153
+ const summary = tagged
154
+ ? `\n<p class="note">${total - tagged} of ${total} cells are anchored to a source. The other ${tagged} ${verb} <code>? verify</code>, so nothing anchors them yet.</p>`
155
+ : ''
78
156
  return `<!doctype html>
79
157
  <html lang="en">
80
158
  <head>
@@ -91,12 +169,12 @@ function buildHtml(doc: DesignDoc): string {
91
169
  .swatch { display: inline-block; width: 1.5rem; height: 1.5rem; border-radius: 4px; border: 1px solid #ddd; vertical-align: middle; margin-right: 0.5rem; }
92
170
  .bar { display: inline-block; height: 1rem; background: #888; border-radius: 2px; vertical-align: middle; }
93
171
  .note { color: #666; font-size: 13px; margin-top: 0.5rem; }
94
- .empty { color: #999; font-style: italic; }
172
+ .empty { color: #999; font-style: italic; }${verifyStyle}
95
173
  </style>
96
174
  </head>
97
175
  <body>
98
176
  <h1>Design tokens</h1>
99
- <p class="note">Generated from <code>.claude/DESIGN.md</code> by <code>aitk design render</code>. Token preview only, not a screen mock.</p>
177
+ <p class="note">Generated from <code>.claude/DESIGN.md</code> by <code>aitk design render</code>. Token preview only, not a screen mock.</p>${summary}
100
178
  ${sections.join('\n')}
101
179
  </body>
102
180
  </html>
@@ -112,11 +190,12 @@ function sectionColor(rows: Row[]): string {
112
190
  if (!rows.length) return ''
113
191
  const body = rows
114
192
  .map((r) => {
115
- const swatch = r['Value']
116
- ? `<span class="swatch" style="background:${escape(r['Value'])}"></span>`
193
+ const value = val(r, 'Value')
194
+ const swatch = value
195
+ ? `<span class="swatch" style="background:${escape(value)}"></span>`
117
196
  : '<span class="swatch"></span>'
118
- const value = r['Value'] || '<span class="empty">unset</span>'
119
- return `<tr><td>${swatch}${escape(r['Role'] ?? '')}</td><td>${escape(r['Intent'] ?? '')}</td><td><code>${value}</code></td></tr>`
197
+ const shown = value ? escape(value) : '<span class="empty">unset</span>'
198
+ return `<tr><td>${swatch}${cellText(r, 'Role')}</td><td>${cellText(r, 'Intent')}</td><td><code>${shown}</code>${mark(r, 'Value')}</td></tr>`
120
199
  })
121
200
  .join('\n')
122
201
  return `<h2>Color</h2>\n<table><thead><tr><th>Role</th><th>Intent</th><th>Value</th></tr></thead><tbody>${body}</tbody></table>`
@@ -126,12 +205,12 @@ function sectionTypography(rows: Row[]): string {
126
205
  if (!rows.length) return ''
127
206
  const body = rows
128
207
  .map((r) => {
129
- const family = r['Family'] || 'system-ui'
130
- const weight = r['Weight'] || '400'
131
- const size = r['Size'] || '16px'
132
- const lh = r['Line height'] || '1.4'
208
+ const family = val(r, 'Family') || 'system-ui'
209
+ const weight = val(r, 'Weight') || '400'
210
+ const size = val(r, 'Size') || '16px'
211
+ const lh = val(r, 'Line height') || '1.4'
133
212
  const sample = `<span style="font-family:${escape(family)};font-weight:${escape(weight)};font-size:${escape(size)};line-height:${escape(lh)}">The quick brown fox</span>`
134
- return `<tr><td>${escape(r['Role'] ?? '')}</td><td>${escape(family)}</td><td>${escape(weight)}</td><td>${escape(size)}</td><td>${escape(lh)}</td><td>${sample}</td></tr>`
213
+ return `<tr><td>${cellText(r, 'Role')}</td><td>${escape(family)}${mark(r, 'Family')}</td><td>${escape(weight)}${mark(r, 'Weight')}</td><td>${escape(size)}${mark(r, 'Size')}</td><td>${escape(lh)}${mark(r, 'Line height')}</td><td>${sample}</td></tr>`
135
214
  })
136
215
  .join('\n')
137
216
  return `<h2>Typography</h2>\n<table><thead><tr><th>Role</th><th>Family</th><th>Weight</th><th>Size</th><th>Line height</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
@@ -141,11 +220,11 @@ function sectionSpacing(rows: Row[]): string {
141
220
  if (!rows.length) return ''
142
221
  const body = rows
143
222
  .map((r) => {
144
- const value = r['Value'] || ''
223
+ const value = val(r, 'Value')
145
224
  const bar = value
146
225
  ? `<span class="bar" style="width:${escape(value)}"></span>`
147
226
  : '<span class="empty">unset</span>'
148
- return `<tr><td>${escape(r['Step'] ?? '')}</td><td>${escape(r['Multiplier'] ?? '')}</td><td><code>${escape(value || 'unset')}</code></td><td>${bar}</td></tr>`
227
+ return `<tr><td>${cellText(r, 'Step')}</td><td>${cellText(r, 'Multiplier')}</td><td><code>${escape(value || 'unset')}</code>${mark(r, 'Value')}</td><td>${bar}</td></tr>`
149
228
  })
150
229
  .join('\n')
151
230
  return `<h2>Spacing</h2>\n<table><thead><tr><th>Step</th><th>Multiplier</th><th>Value</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
@@ -155,10 +234,10 @@ function sectionBorders(rows: Row[]): string {
155
234
  if (!rows.length) return ''
156
235
  const body = rows
157
236
  .map((r) => {
158
- const radius = r['Radius'] || '0'
159
- const width = r['Width'] || '1px'
237
+ const radius = val(r, 'Radius') || '0'
238
+ const width = val(r, 'Width') || '1px'
160
239
  const sample = `<span style="display:inline-block;width:2rem;height:1.5rem;background:#eee;border:${escape(width)} solid #888;border-radius:${escape(radius)};vertical-align:middle"></span>`
161
- return `<tr><td>${escape(r['Role'] ?? '')}</td><td><code>${escape(radius)}</code></td><td><code>${escape(width)}</code></td><td>${escape(r['When used'] ?? '')}</td><td>${sample}</td></tr>`
240
+ return `<tr><td>${cellText(r, 'Role')}</td><td><code>${escape(radius)}</code>${mark(r, 'Radius')}</td><td><code>${escape(width)}</code>${mark(r, 'Width')}</td><td>${cellText(r, 'When used')}</td><td>${sample}</td></tr>`
162
241
  })
163
242
  .join('\n')
164
243
  return `<h2>Borders</h2>\n<table><thead><tr><th>Role</th><th>Radius</th><th>Width</th><th>When used</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
@@ -22,12 +22,10 @@ import {
22
22
  logStep,
23
23
  logWarn,
24
24
  outro,
25
+ palette,
25
26
  select,
26
27
  } from '@/ui'
27
28
 
28
- const GREEN = '\x1b[0;32m'
29
- const NC = '\x1b[0m'
30
-
31
29
  /**
32
30
  * One installed file in three path flavours: absolute, relative to the
33
31
  * domain's installed root, and relative to the target. Adapters match on
@@ -304,6 +302,7 @@ export async function runDomainSync(
304
302
  }
305
303
 
306
304
  const plan = planSync(adapter, resolved)
305
+ const { GREEN, GREY, NC } = palette(process.stderr)
307
306
 
308
307
  if (
309
308
  !existsSync(adapter.installedRoot(resolved)) &&
@@ -358,7 +357,7 @@ export async function runDomainSync(
358
357
 
359
358
  outro()
360
359
  process.stderr.write(
361
- `${GREEN}✓ Sync complete${NC} \x1b[0;90m(${count} ${adapter.unit})${NC}\n`,
360
+ `${GREEN}✓ Sync complete${NC} ${GREY}(${count} ${adapter.unit})${NC}\n`,
362
361
  )
363
362
  return 0
364
363
  }
@@ -19,13 +19,11 @@ import {
19
19
  logStep,
20
20
  logWarn,
21
21
  outro,
22
+ palette,
22
23
  pipeOutput,
23
24
  select,
24
25
  } from '@/ui'
25
26
 
26
- const GREEN = '\x1b[0;32m'
27
- const NC = '\x1b[0m'
28
-
29
27
  const PROTECTED_BRANCHES: readonly string[] = ['main', 'master']
30
28
 
31
29
  export type WorkflowChoice = 'pr' | 'commit' | 'cancel'
@@ -195,6 +193,7 @@ function promptChoice(canOpenPullRequest: boolean): Promise<WorkflowChoice> {
195
193
  }
196
194
 
197
195
  function succeed(message: string): number {
196
+ const { GREEN, NC } = palette(process.stderr)
198
197
  outro()
199
198
  process.stderr.write(`\n${GREEN}✓ ${message}${NC}\n`)
200
199
  return 0
package/src/ui.ts CHANGED
@@ -1,11 +1,60 @@
1
- const GREEN = '\x1b[0;32m'
2
- const RED = '\x1b[0;31m'
3
- const YELLOW = '\x1b[0;33m'
4
- const WHITE = '\x1b[1;37m'
5
- const GREY = '\x1b[0;90m'
6
- const NC = '\x1b[0m'
1
+ export interface Palette {
2
+ readonly GREEN: string
3
+ readonly RED: string
4
+ readonly YELLOW: string
5
+ readonly WHITE: string
6
+ readonly GREY: string
7
+ readonly NC: string
8
+ }
9
+
10
+ const COLOR: Palette = {
11
+ GREEN: '\x1b[0;32m',
12
+ RED: '\x1b[0;31m',
13
+ YELLOW: '\x1b[0;33m',
14
+ WHITE: '\x1b[1;37m',
15
+ GREY: '\x1b[0;90m',
16
+ NC: '\x1b[0m',
17
+ }
18
+
19
+ /**
20
+ * The blank palette keeps every frame character and drops only the escapes, so
21
+ * a captured run still reads as one block.
22
+ */
23
+ const PLAIN: Palette = {
24
+ GREEN: '',
25
+ RED: '',
26
+ YELLOW: '',
27
+ WHITE: '',
28
+ GREY: '',
29
+ NC: '',
30
+ }
31
+
32
+ /**
33
+ * `NO_COLOR` follows the published convention, where any non-empty value turns
34
+ * color off whatever the value says.
35
+ */
36
+ export function supportsColor(stream: { isTTY?: boolean }): boolean {
37
+ const optOut = process.env.NO_COLOR
38
+ if (optOut !== undefined && optOut !== '') return false
39
+ return stream.isTTY === true
40
+ }
41
+
42
+ /**
43
+ * The question is asked per stream rather than once for the process. The framed
44
+ * output goes to stderr and a structured record to stdout, so a run piping only
45
+ * its data keeps a terminal on stderr and keeps its color there. This is a
46
+ * third question again from `isNonInteractive`, which answers whether a caller
47
+ * can be prompted rather than whether a destination renders escapes.
48
+ *
49
+ * Read at write time rather than at import, so nothing freezes an answer taken
50
+ * before the caller's environment was in place.
51
+ */
52
+ export function palette(stream: { isTTY?: boolean }): Palette {
53
+ return supportsColor(stream) ? COLOR : PLAIN
54
+ }
7
55
 
8
56
  export function intro(title: string): void {
57
+ const { GREY, NC, WHITE } = palette(process.stderr)
9
58
  process.stderr.write(`${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}${title}${NC}\n`)
10
59
  }
11
60
 
@@ -15,18 +64,22 @@ export function intro(title: string): void {
15
64
  * leaving stdout clean for JSON and lists.
16
65
  */
17
66
  export function logInfo(message: string): void {
67
+ const { GREEN, GREY, NC } = palette(process.stderr)
18
68
  process.stderr.write(`${GREY}│${NC} ${GREEN}✓${NC} ${message}\n`)
19
69
  }
20
70
 
21
71
  export function logWarn(message: string): void {
72
+ const { GREY, NC, YELLOW } = palette(process.stderr)
22
73
  process.stderr.write(`${GREY}│${NC} ${YELLOW}!${NC} ${message}\n`)
23
74
  }
24
75
 
25
76
  export function logAdd(message: string): void {
77
+ const { GREEN, GREY, NC } = palette(process.stderr)
26
78
  process.stderr.write(`${GREY}│${NC} ${GREEN}+${NC} ${message}\n`)
27
79
  }
28
80
 
29
81
  export function logRemove(message: string): void {
82
+ const { GREY, NC, RED } = palette(process.stderr)
30
83
  process.stderr.write(`${GREY}│${NC} ${RED}-${NC} ${message}\n`)
31
84
  }
32
85
 
@@ -36,14 +89,17 @@ export function logRemove(message: string): void {
36
89
  * exit code rather than terminating mid-write.
37
90
  */
38
91
  export function logError(message: string): void {
92
+ const { GREY, NC, RED } = palette(process.stderr)
39
93
  process.stderr.write(`${GREY}│${NC} ${RED}✗${NC} ${message}\n`)
40
94
  }
41
95
 
42
96
  export function logStep(message: string): void {
97
+ const { GREY, NC, WHITE } = palette(process.stderr)
43
98
  process.stderr.write(`${GREY}│${NC}\n${GREY}├${NC} ${WHITE}${message}${NC}\n`)
44
99
  }
45
100
 
46
101
  export function outro(): void {
102
+ const { GREY, NC } = palette(process.stderr)
47
103
  process.stderr.write(`${GREY}└${NC}\n`)
48
104
  }
49
105
 
@@ -53,6 +109,7 @@ export function outro(): void {
53
109
  * such as a pull request body or the output of a git mutation.
54
110
  */
55
111
  export function pipeOutput(text: string): void {
112
+ const { GREY, NC } = palette(process.stderr)
56
113
  const lines = text.replace(/\n$/, '').split('\n')
57
114
  process.stderr.write(
58
115
  `${lines.map((line) => `${GREY}│${NC} ${line}`).join('\n')}\n`,
@@ -65,12 +122,14 @@ export function plural(count: number, noun: string): string {
65
122
  }
66
123
 
67
124
  export function frameError(message: string): void {
125
+ const { GREY, NC, RED } = palette(process.stderr)
68
126
  process.stderr.write(
69
127
  `${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} ${message}\n${GREY}└${NC}\n`,
70
128
  )
71
129
  }
72
130
 
73
131
  export function frameSuccess(command: string, target: string): void {
132
+ const { GREEN, GREY, NC, WHITE } = palette(process.stderr)
74
133
  process.stderr.write(
75
134
  `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}${command}${NC}\n${GREY}│${NC}\n${GREY}│${NC} ${GREEN}✓${NC} ${target}\n${GREY}└${NC}\n`,
76
135
  )
@@ -92,6 +151,7 @@ export async function select<Value>(opts: {
92
151
  nonInteractiveDefault?: boolean
93
152
  }): Promise<Value> {
94
153
  const { message, options } = opts
154
+ const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
95
155
  const count = options.length
96
156
  let cursor = 0
97
157
 
@@ -34,6 +34,16 @@ Does not govern:
34
34
  - Plain English over technical notation. If a section could be removed and the developer would still build correctly from wireframes and code alone, remove it.
35
35
  - Keep table headers and role names intact so the render tooling can parse the token tables.
36
36
 
37
+ ## The uncertainty tag
38
+
39
+ A cell no source anchors ends in ` ? verify`, written inside the cell rather than as a trailing column, since a trailing marker breaks the table parse. A cell wrapping itself in a code span carries the tag inside the span, as in `` `#ffffff ? verify` ``. Both spellings parse.
40
+
41
+ The renderer splits the tag off the value, so a swatch and a font sample are built from the value alone and the marker shows beside it. The preview also reports how many cells are anchored against how many are tagged, which is the reading a reviewer takes the record's overall confidence from.
42
+
43
+ Which columns that ratio reads is fixed by the table rather than by the record. The first column of each table names its row, and `Multiplier` and `When used` restate what the row already carries, so none of them is something a source could anchor and none is counted. That leaves `Intent` and `Value` in Color, `Family`, `Weight`, `Size`, and `Line height` in Typography, `Value` in Spacing, and `Radius` and `Width` in Borders. A cell tagged outside that set counts anyway, so a marker the preview draws is never missing from the ratio beside it.
44
+
45
+ A prose section takes its uncertainty inline instead, in a sentence saying what is proposed and what has yet to confirm it. A tag appended to a paragraph renders verbatim.
46
+
37
47
  ## Sections
38
48
 
39
49
  Use `## Personality`, `## Color`, `## Typography`, `## Spacing`, `## Borders`, `## Motion`, and `## Iconography`. The token tables carry fixed headers the renderer reads.