@erclx/aitk 1.3.0 → 1.5.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,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.