@erclx/canon 5.17.0 → 5.19.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.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
  4. package/claude/skills/deploy-app/SKILL.md +45 -0
  5. package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
  6. package/claude/skills/deploy-app/references/vercel.md +72 -0
  7. package/docs/agents/architecture-record.md +12 -4
  8. package/docs/agents/canvas.md +13 -1
  9. package/docs/agents/commands.md +3 -2
  10. package/docs/agents/context-audit-checks.md +1 -1
  11. package/docs/agents/context-audit.md +4 -4
  12. package/docs/agents/index.md +2 -2
  13. package/docs/agents/teach.md +7 -1
  14. package/docs/workflow/skill-map.md +9 -9
  15. package/package.json +2 -1
  16. package/src/audits/catalog.ts +46 -1
  17. package/src/canvas/address.ts +73 -39
  18. package/src/canvas/client/inspector.tsx +241 -33
  19. package/src/canvas/client/pages-panel.tsx +51 -0
  20. package/src/canvas/client/shell.css +126 -1
  21. package/src/canvas/client/state.ts +120 -4
  22. package/src/canvas/client/theme-panel.tsx +55 -0
  23. package/src/canvas/content.ts +14 -3
  24. package/src/canvas/edit.ts +360 -0
  25. package/src/canvas/server.ts +50 -5
  26. package/src/canvas/tokens.ts +82 -0
  27. package/src/claude/cases/misc.ts +5 -1
  28. package/src/commands/canvas.ts +95 -1
  29. package/src/commands/context.ts +85 -5
  30. package/src/commands/slides.ts +73 -4
  31. package/src/commands/teach.ts +112 -4
  32. package/src/context/architecture.ts +98 -18
  33. package/src/context/gate.ts +30 -3
  34. package/src/context/requirements.ts +77 -0
  35. package/src/gate/measures.ts +69 -28
  36. package/src/gate/stages.ts +5 -5
  37. package/src/serve/live.ts +113 -0
  38. package/src/serve/static.ts +60 -2
  39. package/src/slides/convert/export.ts +323 -0
  40. package/src/slides/convert/shapes.ts +764 -0
  41. package/src/slides/convert/svg.ts +38 -0
  42. package/src/slides/convert/walk.ts +444 -0
  43. package/src/teach/browser/reload.ts +9 -0
  44. package/src/teach/nav.ts +7 -6
  45. package/src/teach/workspace.ts +2 -1
  46. package/src/teach/write-if-changed.ts +17 -0
  47. package/standards/architecture.md +13 -7
  48. package/standards/requirements.md +4 -0
  49. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  50. package/tooling/cloudflare/reference.md +1 -1
  51. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
@@ -90,6 +90,58 @@ interface Collected {
90
90
  text: string
91
91
  }
92
92
 
93
+ /**
94
+ * Called on each element the address counts, with its index. What it returns
95
+ * runs at the element's end tag, since a second `onEndTag` on one element
96
+ * replaces the first rather than adding to it.
97
+ */
98
+ export type ElementVisitor = (
99
+ element: HTMLRewriterTypes.Element,
100
+ index: number,
101
+ ) => (() => void) | undefined
102
+
103
+ /**
104
+ * Registers the one counting rule both the reader and the edit writer follow,
105
+ * so an index means the same element to each.
106
+ */
107
+ export function countElements(
108
+ rewriter: HTMLRewriter,
109
+ visit: ElementVisitor,
110
+ ): HTMLRewriter {
111
+ let count = 0
112
+ /* A template's content is a fragment `querySelectorAll` never reaches. */
113
+ let templateDepth = 0
114
+
115
+ return rewriter.on('*', {
116
+ element(element) {
117
+ const tag = element.tagName.toLowerCase()
118
+ if (templateDepth > 0) {
119
+ if (tag === 'template') {
120
+ templateDepth += 1
121
+ element.onEndTag(() => {
122
+ templateDepth -= 1
123
+ })
124
+ }
125
+ return
126
+ }
127
+ const end = visit(element, count)
128
+ count += 1
129
+ if (!element.canHaveContent || element.selfClosing) return
130
+ const isTemplate = tag === 'template'
131
+ if (isTemplate) templateDepth += 1
132
+ element.onEndTag(() => {
133
+ if (isTemplate) templateDepth -= 1
134
+ end?.()
135
+ })
136
+ },
137
+ })
138
+ }
139
+
140
+ /** Whether an element's content is raw text rather than parsed markup. */
141
+ export function isRawText(tag: string): boolean {
142
+ return RAW_TEXT.has(tag)
143
+ }
144
+
93
145
  /**
94
146
  * Every element the file states, in document order. Text is gathered into
95
147
  * each open element, which an element closed only by implication keeps open to
@@ -99,8 +151,6 @@ export function sourceElements(html: string): SourceElement[] {
99
151
  const elements: Collected[] = []
100
152
  const open: number[] = []
101
153
  let rawDepth = 0
102
- /* A template's content is a fragment `querySelectorAll` never reaches. */
103
- let templateDepth = 0
104
154
 
105
155
  const appendText = (text: string) => {
106
156
  for (const index of open) {
@@ -111,44 +161,28 @@ export function sourceElements(html: string): SourceElement[] {
111
161
  }
112
162
  }
113
163
 
114
- new HTMLRewriter()
115
- .on('*', {
116
- element(element) {
117
- const tag = element.tagName.toLowerCase()
118
- if (templateDepth > 0) {
119
- if (tag === 'template') {
120
- templateDepth += 1
121
- element.onEndTag(() => {
122
- templateDepth -= 1
123
- })
124
- }
125
- return
126
- }
127
- const index = elements.length
128
- /* A tag boundary separates words the way a rendered block would. */
129
- appendText(' ')
130
- elements.push({
131
- tag,
132
- classes: (element.getAttribute('class') ?? '')
133
- .split(/\s+/)
134
- .filter(Boolean),
135
- text: '',
136
- })
137
- if (!element.canHaveContent || element.selfClosing) return
138
- const isRaw = RAW_TEXT.has(tag)
139
- const isTemplate = tag === 'template'
140
- open.push(index)
141
- if (isRaw) rawDepth += 1
142
- if (isTemplate) templateDepth += 1
143
- element.onEndTag(() => {
144
- const at = open.lastIndexOf(index)
145
- if (at !== -1) open.splice(at, 1)
146
- if (isRaw) rawDepth -= 1
147
- if (isTemplate) templateDepth -= 1
148
- appendText(' ')
149
- })
150
- },
164
+ countElements(new HTMLRewriter(), (element, index) => {
165
+ const tag = element.tagName.toLowerCase()
166
+ /* A tag boundary separates words the way a rendered block would. */
167
+ appendText(' ')
168
+ elements.push({
169
+ tag,
170
+ classes: (element.getAttribute('class') ?? '')
171
+ .split(/\s+/)
172
+ .filter(Boolean),
173
+ text: '',
151
174
  })
175
+ if (!element.canHaveContent || element.selfClosing) return undefined
176
+ const isRaw = isRawText(tag)
177
+ open.push(index)
178
+ if (isRaw) rawDepth += 1
179
+ return () => {
180
+ const at = open.lastIndexOf(index)
181
+ if (at !== -1) open.splice(at, 1)
182
+ if (isRaw) rawDepth -= 1
183
+ appendText(' ')
184
+ }
185
+ })
152
186
  .onDocument({
153
187
  text(chunk) {
154
188
  if (rawDepth === 0) appendText(chunk.text)
@@ -1,82 +1,286 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
- import { elementAt } from '@/canvas/address'
3
+ import { addressOf, elementAt, excerpt, isRawText } from '@/canvas/address'
4
4
  import {
5
5
  currentPage,
6
+ editElement,
7
+ editRefusal,
8
+ type FrameRef,
6
9
  frameDocuments,
7
10
  frameKey,
11
+ frameVersions,
12
+ pendingEdit,
13
+ savedEdit,
8
14
  selectedElement,
9
15
  selectedFrame,
16
+ tokens,
10
17
  writeError,
11
18
  } from '@/canvas/client/state'
12
19
 
13
20
  const FIELDS = ['x', 'y', 'width', 'height'] as const
14
21
 
22
+ /** Elements the parser never gives content, so they hold no text to edit. */
23
+ const VOID = new Set([
24
+ 'area',
25
+ 'base',
26
+ 'br',
27
+ 'col',
28
+ 'embed',
29
+ 'hr',
30
+ 'img',
31
+ 'input',
32
+ 'link',
33
+ 'meta',
34
+ 'source',
35
+ 'track',
36
+ 'wbr',
37
+ ])
38
+
39
+ interface StyleField {
40
+ readonly label: string
41
+ readonly property: string
42
+ readonly isColor?: boolean
43
+ }
44
+
45
+ /**
46
+ * The basic set, in the order the panel lists it. The writer refuses any
47
+ * property outside it, so this list and the writer's have to agree.
48
+ */
49
+ const STYLE_FIELDS: readonly StyleField[] = [
50
+ { label: 'color', property: 'color', isColor: true },
51
+ { label: 'background', property: 'background-color', isColor: true },
52
+ { label: 'size', property: 'font-size' },
53
+ { label: 'weight', property: 'font-weight' },
54
+ { label: 'width', property: 'width' },
55
+ { label: 'height', property: 'height' },
56
+ { label: 'padding', property: 'padding' },
57
+ { label: 'gap', property: 'gap' },
58
+ { label: 'direction', property: 'flex-direction' },
59
+ ]
60
+
15
61
  interface Row {
16
62
  readonly name: string
17
63
  readonly value: string
18
64
  }
19
65
 
20
- /**
21
- * The computed values the browser settled on, which is what the operator sees
22
- * rather than what any one stylesheet states.
23
- */
24
- function styleRows(element: Element): Row[] {
66
+ /** Where the element sits in its frame, which the panel shows and never edits. */
67
+ function positionRows(element: Element): Row[] {
25
68
  const rect = element.getBoundingClientRect()
26
- const view = element.ownerDocument.defaultView
27
- const style = view?.getComputedStyle(element)
28
- const rows: Row[] = [
69
+ const font = element.ownerDocument.defaultView
70
+ ?.getComputedStyle(element)
71
+ .getPropertyValue('font-family')
72
+ return [
29
73
  { name: 'x', value: String(Math.round(rect.left)) },
30
74
  { name: 'y', value: String(Math.round(rect.top)) },
31
- { name: 'width', value: String(Math.round(rect.width)) },
32
- { name: 'height', value: String(Math.round(rect.height)) },
33
- ]
34
- if (!style) return rows
35
- return [
36
- ...rows,
37
- { name: 'color', value: style.color },
38
- { name: 'background', value: style.backgroundColor },
39
- { name: 'font', value: style.fontFamily },
40
- { name: 'size', value: style.fontSize },
41
- { name: 'weight', value: style.fontWeight },
75
+ ...(font ? [{ name: 'font', value: font }] : []),
42
76
  ]
43
77
  }
44
78
 
79
+ /**
80
+ * What the element states inline, else the value the browser settled on, so
81
+ * an edit starts from what the operator sees.
82
+ */
83
+ function currentValue(element: Element, property: string): string {
84
+ const inline =
85
+ 'style' in element
86
+ ? (element as HTMLElement).style.getPropertyValue(property)
87
+ : ''
88
+ if (inline) return inline
89
+ return (
90
+ element.ownerDocument.defaultView
91
+ ?.getComputedStyle(element)
92
+ .getPropertyValue(property) ?? ''
93
+ )
94
+ }
95
+
96
+ function holdsTextAlone(element: Element): boolean {
97
+ const tag = element.tagName.toLowerCase()
98
+ return element.children.length === 0 && !VOID.has(tag) && !isRawText(tag)
99
+ }
100
+
45
101
  function elementName(element: Element): string {
46
102
  const tag = element.tagName.toLowerCase()
47
103
  return [tag, ...element.classList].join('.')
48
104
  }
49
105
 
50
- /** Read-only in this slice, since editing an element is the edit slice's work. */
106
+ interface FieldProps {
107
+ readonly label: string
108
+ readonly initial: string
109
+ readonly isBusy: boolean
110
+ readonly onCommit: (value: string) => void
111
+ }
112
+
113
+ /**
114
+ * Commits on change, which a text input fires on Enter or on leaving it, and
115
+ * sends nothing when the value is what it started at. Escape puts it back.
116
+ */
117
+ function Field({ label, initial, isBusy, onCommit }: FieldProps): JSX.Element {
118
+ return (
119
+ <input
120
+ class="field"
121
+ type="text"
122
+ aria-label={label}
123
+ title={initial}
124
+ defaultValue={initial}
125
+ disabled={isBusy}
126
+ spellcheck={false}
127
+ onChange={(event) => {
128
+ const value = event.currentTarget.value.trim()
129
+ if (value !== initial.trim()) onCommit(value)
130
+ }}
131
+ onKeyDown={(event) => {
132
+ if (event.key === 'Escape') event.currentTarget.value = initial
133
+ }}
134
+ />
135
+ )
136
+ }
137
+
138
+ function TokenPicker({
139
+ label,
140
+ initial,
141
+ isBusy,
142
+ onCommit,
143
+ }: FieldProps): JSX.Element | null {
144
+ const colors =
145
+ tokens.value?.groups?.find((group) => group.kind === 'color')?.tokens ?? []
146
+ if (colors.length === 0) return null
147
+ return (
148
+ <select
149
+ class="token-picker"
150
+ aria-label={`${label} token`}
151
+ title="Pick a project token"
152
+ value={
153
+ colors.some((token) => initial === `var(${token.name})`) ? initial : ''
154
+ }
155
+ disabled={isBusy}
156
+ onChange={(event) => {
157
+ const { value } = event.currentTarget
158
+ if (value !== '' && value !== initial) onCommit(value)
159
+ }}
160
+ >
161
+ <option value="">Token</option>
162
+ {colors.map((token) => (
163
+ <option key={token.name} value={`var(${token.name})`}>
164
+ {token.name}
165
+ </option>
166
+ ))}
167
+ </select>
168
+ )
169
+ }
170
+
171
+ function ElementFields({
172
+ node,
173
+ doc,
174
+ frameRef,
175
+ frameKey: key,
176
+ }: {
177
+ readonly node: Element
178
+ readonly doc: Document
179
+ readonly frameRef: FrameRef
180
+ readonly frameKey: string
181
+ }): JSX.Element {
182
+ const address = addressOf(doc, node)
183
+ const pending = pendingEdit.value
184
+ const isBusy = pending?.key === key && pending.index === address?.index
185
+ const commit = (property: string) => (value: string) => {
186
+ if (address) void editElement(frameRef, key, address, property, value)
187
+ }
188
+ const isTextOnly = holdsTextAlone(node)
189
+ const text = node.textContent ?? ''
190
+
191
+ return (
192
+ <dl class="box styles">
193
+ {positionRows(node).map((row) => (
194
+ <div key={row.name} class="box-row">
195
+ <dt>{row.name}</dt>
196
+ <dd title={row.value}>{row.value}</dd>
197
+ </div>
198
+ ))}
199
+ <div class="box-row">
200
+ <dt>text</dt>
201
+ <dd title={excerpt(text)}>
202
+ {isTextOnly ? (
203
+ <Field
204
+ label="text"
205
+ initial={text}
206
+ isBusy={isBusy}
207
+ onCommit={commit('text')}
208
+ />
209
+ ) : (
210
+ excerpt(text)
211
+ )}
212
+ </dd>
213
+ </div>
214
+ {STYLE_FIELDS.map((field) => {
215
+ const initial = currentValue(node, field.property)
216
+ return (
217
+ <div key={field.property} class="box-row">
218
+ <dt>{field.label}</dt>
219
+ <dd class="field-cell">
220
+ <Field
221
+ label={field.label}
222
+ initial={initial}
223
+ isBusy={isBusy}
224
+ onCommit={commit(field.property)}
225
+ />
226
+ {field.isColor ? (
227
+ <TokenPicker
228
+ label={field.label}
229
+ initial={initial}
230
+ isBusy={isBusy}
231
+ onCommit={commit(field.property)}
232
+ />
233
+ ) : null}
234
+ </dd>
235
+ </div>
236
+ )
237
+ })}
238
+ </dl>
239
+ )
240
+ }
241
+
51
242
  function ElementDetails(): JSX.Element | null {
52
243
  const picked = selectedElement.value
53
244
  const page = currentPage.value
54
245
  if (!picked || !page) return null
55
246
  const { frame, element } = picked
56
- const doc = frameDocuments.value.get(frameKey(page.name, frame))
247
+ const key = frameKey(page.name, frame)
248
+ const doc = frameDocuments.value.get(key)
57
249
  const node = doc && !element.stale ? elementAt(doc, element) : undefined
250
+ const saved = savedEdit.value
251
+ const isSaved = saved?.key === key && saved.index === element.index
58
252
 
59
253
  return (
60
254
  <section aria-label="Element">
61
- <h2 class="section-label">Element</h2>
255
+ <h2 class="section-label">
256
+ Element
257
+ {isSaved ? (
258
+ <span class="saved" role="status">
259
+ Saved
260
+ </span>
261
+ ) : null}
262
+ </h2>
62
263
  {element.stale ? (
63
264
  <p class="notice">
64
265
  The frame changed since this element was picked, so its index may name
65
266
  another element now. Pick it again.
66
267
  </p>
67
- ) : node ? (
268
+ ) : doc && node ? (
68
269
  <>
69
270
  <p class="detail" title={elementName(node)}>
70
271
  {elementName(node)}
71
272
  </p>
72
- <dl class="box styles">
73
- {styleRows(node).map((row) => (
74
- <div key={row.name} class="box-row">
75
- <dt>{row.name}</dt>
76
- <dd title={row.value}>{row.value}</dd>
77
- </div>
78
- ))}
79
- </dl>
273
+ {/*
274
+ * Keyed by the frame's version and the element, so a reload drops
275
+ * any value typed against the document it replaced.
276
+ */}
277
+ <ElementFields
278
+ key={`${frameVersions.value.get(key) ?? 0}:${element.index}`}
279
+ node={node}
280
+ doc={doc}
281
+ frameRef={{ page: page.name, frame: frame.name }}
282
+ frameKey={key}
283
+ />
80
284
  </>
81
285
  ) : (
82
286
  <p class="empty">Loading the frame to read this element</p>
@@ -85,7 +289,6 @@ function ElementDetails(): JSX.Element | null {
85
289
  )
86
290
  }
87
291
 
88
- /** Read-only in this slice, since editing a box is the edit slice's work. */
89
292
  export function Inspector(): JSX.Element {
90
293
  const frame = selectedFrame.value
91
294
  return (
@@ -116,6 +319,11 @@ export function Inspector(): JSX.Element {
116
319
  ) : null}
117
320
  </section>
118
321
  <ElementDetails />
322
+ {editRefusal.value ? (
323
+ <p class="notice" role="alert">
324
+ {editRefusal.value}
325
+ </p>
326
+ ) : null}
119
327
  </>
120
328
  )
121
329
  }
@@ -1,12 +1,15 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
3
  import { Layers } from '@/canvas/client/layers'
4
+ import { ThemePanel } from '@/canvas/client/theme-panel'
4
5
  import type { Frame } from '@/canvas/content'
5
6
  import {
6
7
  currentPage,
7
8
  expandedFrames,
8
9
  frameKey,
9
10
  isLoaded,
11
+ type LeftTab,
12
+ leftTab,
10
13
  loadError,
11
14
  pages,
12
15
  selectedFrame,
@@ -127,6 +130,48 @@ function FrameList({ onFocusFrame }: PagesPanelProps): JSX.Element | null {
127
130
  )
128
131
  }
129
132
 
133
+ const TABS: readonly { readonly id: LeftTab; readonly label: string }[] = [
134
+ { id: 'pages', label: 'Pages' },
135
+ { id: 'theme', label: 'Theme' },
136
+ ]
137
+
138
+ /** Two tabs, so the arrow keys move between them and wrap. */
139
+ function Tabs(): JSX.Element {
140
+ const select = (at: number) => {
141
+ const tab = TABS[(at + TABS.length) % TABS.length]
142
+ if (!tab) return
143
+ leftTab.value = tab.id
144
+ document.getElementById(`tab-${tab.id}`)?.focus()
145
+ }
146
+ return (
147
+ <div class="tabs" role="tablist" aria-label="Left panel">
148
+ {TABS.map((tab, at) => {
149
+ const isSelected = leftTab.value === tab.id
150
+ return (
151
+ <button
152
+ key={tab.id}
153
+ id={`tab-${tab.id}`}
154
+ type="button"
155
+ role="tab"
156
+ class="tab"
157
+ aria-selected={isSelected}
158
+ tabIndex={isSelected ? 0 : -1}
159
+ onClick={() => {
160
+ leftTab.value = tab.id
161
+ }}
162
+ onKeyDown={(event) => {
163
+ if (event.key === 'ArrowRight') select(at + 1)
164
+ if (event.key === 'ArrowLeft') select(at - 1)
165
+ }}
166
+ >
167
+ {tab.label}
168
+ </button>
169
+ )
170
+ })}
171
+ </div>
172
+ )
173
+ }
174
+
130
175
  export function PagesPanel(props: PagesPanelProps): JSX.Element {
131
176
  return (
132
177
  <nav class="panel panel-left" aria-label="Pages and frames">
@@ -140,8 +185,14 @@ export function PagesPanel(props: PagesPanelProps): JSX.Element {
140
185
  </p>
141
186
  ) : !isLoaded.value ? (
142
187
  <p class="empty">Loading</p>
188
+ ) : leftTab.value === 'theme' ? (
189
+ <>
190
+ <Tabs />
191
+ <ThemePanel />
192
+ </>
143
193
  ) : (
144
194
  <>
195
+ <Tabs />
145
196
  <h2 class="section-label">Pages</h2>
146
197
  <PageList />
147
198
  {currentPage.value ? (
@@ -39,7 +39,7 @@ code {
39
39
 
40
40
  .shell {
41
41
  display: grid;
42
- grid-template-columns: 15rem minmax(0, 1fr) 13rem;
42
+ grid-template-columns: 15rem minmax(0, 1fr) 16rem;
43
43
  height: 100%;
44
44
  }
45
45
 
@@ -447,3 +447,128 @@ code {
447
447
  text-overflow: ellipsis;
448
448
  white-space: nowrap;
449
449
  }
450
+
451
+ .styles .box-row {
452
+ align-items: center;
453
+ }
454
+
455
+ .styles dt {
456
+ flex: none;
457
+ width: 4.5rem;
458
+ }
459
+
460
+ .styles dd {
461
+ flex: 1;
462
+ }
463
+
464
+ .field-cell {
465
+ display: flex;
466
+ gap: var(--space-xs);
467
+ }
468
+
469
+ .field,
470
+ .token-picker {
471
+ min-width: 0;
472
+ min-height: 24px;
473
+ padding: 2px var(--space-xs);
474
+ border: 1px solid var(--color-border);
475
+ border-radius: var(--radius-action);
476
+ background: var(--color-surface);
477
+ color: var(--color-text);
478
+ font: inherit;
479
+ font-variant-numeric: tabular-nums;
480
+ }
481
+
482
+ .field {
483
+ flex: 1;
484
+ width: 100%;
485
+ }
486
+
487
+ .token-picker {
488
+ flex: none;
489
+ width: 4.5rem;
490
+ cursor: pointer;
491
+ }
492
+
493
+ .field:hover,
494
+ .token-picker:hover {
495
+ border-color: var(--color-muted);
496
+ }
497
+
498
+ .field:disabled,
499
+ .token-picker:disabled {
500
+ opacity: 0.5;
501
+ cursor: not-allowed;
502
+ }
503
+
504
+ .saved {
505
+ margin-left: var(--space-sm);
506
+ color: var(--color-success);
507
+ font-weight: 400;
508
+ }
509
+
510
+ .tabs {
511
+ display: flex;
512
+ gap: 2px;
513
+ margin-top: var(--space-sm);
514
+ padding: 2px;
515
+ border-radius: var(--radius-action);
516
+ background: var(--color-surface);
517
+ }
518
+
519
+ .tab {
520
+ flex: 1;
521
+ min-height: 24px;
522
+ border: 0;
523
+ border-radius: var(--radius-action);
524
+ background: transparent;
525
+ color: var(--color-text-secondary);
526
+ font: inherit;
527
+ cursor: pointer;
528
+ }
529
+
530
+ .tab:hover {
531
+ color: var(--color-text);
532
+ }
533
+
534
+ .tab[aria-selected='true'] {
535
+ background: var(--color-chrome);
536
+ color: var(--color-text);
537
+ }
538
+
539
+ .tokens {
540
+ list-style: none;
541
+ margin: 0;
542
+ padding: 0;
543
+ display: flex;
544
+ flex-direction: column;
545
+ gap: 2px;
546
+ }
547
+
548
+ .token {
549
+ display: flex;
550
+ align-items: center;
551
+ gap: var(--space-sm);
552
+ min-height: 24px;
553
+ padding: 0 var(--space-sm);
554
+ }
555
+
556
+ .token .row-label {
557
+ flex: 1;
558
+ min-width: 0;
559
+ }
560
+
561
+ .token-value {
562
+ max-width: 45%;
563
+ overflow: hidden;
564
+ text-overflow: ellipsis;
565
+ white-space: nowrap;
566
+ }
567
+
568
+ .swatch {
569
+ flex: none;
570
+ width: 14px;
571
+ height: 14px;
572
+ border: 1px solid var(--color-border);
573
+ border-radius: 4px;
574
+ }