@erclx/canon 5.27.0 → 5.29.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 (177) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/draft-ready/SKILL.md +3 -1
  3. package/claude/skills/git-pr/references/evidence.md +1 -1
  4. package/claude/skills/review-address/REQUIREMENT.md +4 -2
  5. package/claude/skills/review-address/SKILL.md +16 -7
  6. package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
  7. package/claude/skills/review-craft/references/ui.md +4 -4
  8. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  9. package/claude/skills/review-ui/SKILL.md +13 -11
  10. package/claude/skills/review-ui/references/driving.md +8 -8
  11. package/claude/skills/review-ui/references/post.md +19 -7
  12. package/claude/skills/review-ui/references/tick.md +22 -0
  13. package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
  14. package/claude/skills/role-orchestrator/SKILL.md +24 -26
  15. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
  16. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +12 -2
  17. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
  20. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
  21. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -2
  22. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
  23. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
  24. package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
  25. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  26. package/claude/skills/role-planner/SKILL.md +2 -2
  27. package/claude/skills/role-reviewer/SKILL.md +1 -1
  28. package/claude/skills/role-worker/SKILL.md +3 -2
  29. package/claude/skills/ui-checklist/SKILL.md +1 -1
  30. package/docs/agents/canvas.md +12 -4
  31. package/docs/agents/gate.md +3 -1
  32. package/docs/agents/index.md +2 -1
  33. package/docs/agents/pr-evidence.md +15 -14
  34. package/docs/agents/pr-tick.md +72 -0
  35. package/docs/agents/sandbox.md +2 -2
  36. package/docs/agents/tasks-plans.md +1 -1
  37. package/docs/agents/tasks-validate.md +18 -18
  38. package/docs/agents/tasks.md +1 -1
  39. package/docs/workflow/operating-model.md +10 -15
  40. package/docs/workflow/parallel-features.md +7 -7
  41. package/package.json +1 -1
  42. package/src/audits/baseline.ts +1 -1
  43. package/src/audits/run.ts +1 -1
  44. package/src/canvas/capture.ts +1 -1
  45. package/src/canvas/client/app.tsx +105 -4
  46. package/src/canvas/client/inspector/field.tsx +61 -2
  47. package/src/canvas/client/inspector/fields.css +78 -0
  48. package/src/canvas/client/inspector/token-select.tsx +109 -0
  49. package/src/canvas/client/inspector/values.ts +74 -2
  50. package/src/canvas/client/inspector.tsx +134 -15
  51. package/src/canvas/client/keys.ts +71 -0
  52. package/src/canvas/client/pages-panel.tsx +5 -1
  53. package/src/canvas/client/panel-handle.tsx +90 -0
  54. package/src/canvas/client/selection.tsx +3 -1
  55. package/src/canvas/client/shell.css +194 -4
  56. package/src/canvas/client/state.ts +255 -11
  57. package/src/canvas/client/surface.tsx +231 -18
  58. package/src/canvas/client/tools.tsx +153 -0
  59. package/src/canvas/content.ts +66 -1
  60. package/src/canvas/edit.ts +207 -25
  61. package/src/canvas/history.ts +116 -0
  62. package/src/canvas/server.ts +119 -13
  63. package/src/canvas/tokens.ts +1 -1
  64. package/src/claude/seeds.ts +2 -2
  65. package/src/claude/skills-reach.ts +1 -1
  66. package/src/{exec.ts → cli/exec.ts} +2 -2
  67. package/src/{help.ts → cli/help.ts} +1 -1
  68. package/src/cli.ts +1 -1
  69. package/src/commands/audits.ts +9 -1
  70. package/src/commands/autoship.ts +1 -1
  71. package/src/commands/canvas.ts +2 -2
  72. package/src/commands/capture.ts +1 -1
  73. package/src/commands/census.ts +1 -1
  74. package/src/commands/claude.ts +5 -5
  75. package/src/commands/comments.ts +1 -1
  76. package/src/commands/context.ts +1 -1
  77. package/src/commands/demo.ts +9 -1
  78. package/src/commands/deps.ts +1 -1
  79. package/src/commands/design.ts +12 -4
  80. package/src/commands/docs.ts +2 -2
  81. package/src/commands/driver.ts +9 -1
  82. package/src/commands/feedback.ts +3 -3
  83. package/src/commands/gate.ts +10 -3
  84. package/src/commands/gov.ts +3 -3
  85. package/src/commands/indexes.ts +1 -1
  86. package/src/commands/init.ts +11 -3
  87. package/src/commands/intake.ts +1 -1
  88. package/src/commands/inventory.ts +9 -1
  89. package/src/commands/labels.ts +1 -1
  90. package/src/commands/markdown.ts +1 -1
  91. package/src/commands/migrate.ts +2 -2
  92. package/src/commands/pr.ts +324 -9
  93. package/src/commands/records.ts +1 -1
  94. package/src/commands/repo.ts +1 -1
  95. package/src/commands/sandbox.ts +11 -14
  96. package/src/commands/secrets.ts +9 -1
  97. package/src/commands/sessions.ts +1 -1
  98. package/src/commands/slides.ts +2 -2
  99. package/src/commands/standards.ts +1 -1
  100. package/src/commands/sync.ts +3 -3
  101. package/src/commands/targets.ts +1 -1
  102. package/src/commands/tasks.ts +20 -3
  103. package/src/commands/teach.ts +1 -1
  104. package/src/commands/tooling.ts +2 -2
  105. package/src/commands/transcripts.ts +2 -2
  106. package/src/commands/upgrade.ts +2 -2
  107. package/src/commands/wiki.ts +2 -2
  108. package/src/commands/worktrees.ts +1 -1
  109. package/src/context/architecture.ts +1 -1
  110. package/src/context/citations.ts +2 -2
  111. package/src/context/classify/extract.ts +1 -1
  112. package/src/context/folders.ts +2 -2
  113. package/src/context/requirements.ts +1 -1
  114. package/src/docs/read.ts +1 -1
  115. package/src/feedback/github.ts +1 -1
  116. package/src/gate/measures.ts +2 -2
  117. package/src/gate/sequencer.ts +55 -3
  118. package/src/gate/stages.ts +8 -0
  119. package/src/gate/suite-lock.ts +214 -0
  120. package/src/init/run.ts +1 -1
  121. package/src/intake/folder.ts +1 -1
  122. package/src/labels/map.ts +1 -1
  123. package/src/labels/phase.ts +3 -3
  124. package/src/migrate/record-layout.ts +1 -1
  125. package/src/migrate/records.ts +11 -11
  126. package/src/migrate/rename.ts +1 -1
  127. package/src/migrate/scratch-evidence.ts +1 -1
  128. package/src/migrate/surface-roots.ts +6 -6
  129. package/src/pr/evidence.ts +188 -16
  130. package/src/pr/tick.ts +56 -0
  131. package/src/process/harness.ts +1 -1
  132. package/src/records/backup.ts +1 -1
  133. package/src/records/ordinal.ts +1 -1
  134. package/src/records/prune.ts +1 -1
  135. package/src/records/size.ts +1 -1
  136. package/src/records/stale-canonical.ts +1 -1
  137. package/src/records/stale.ts +1 -1
  138. package/src/records/validate.ts +2 -2
  139. package/src/{project-root.ts → roots/project.ts} +2 -2
  140. package/src/{record-root.ts → roots/record.ts} +1 -1
  141. package/src/sandbox/coverage.ts +11 -12
  142. package/src/sandbox/equivalence.ts +57 -26
  143. package/src/sandbox/headless/run.ts +1 -1
  144. package/src/sandbox/hooks.ts +1 -1
  145. package/src/sandbox/provision.ts +79 -29
  146. package/src/sandbox/scenario.ts +588 -0
  147. package/src/serve/report.ts +1 -1
  148. package/src/sessions/live.ts +4 -1
  149. package/src/sessions/transfer/bundle.ts +1 -1
  150. package/src/slides/decks.ts +1 -1
  151. package/src/standards/read.ts +1 -1
  152. package/src/sync/engine.ts +2 -2
  153. package/src/sync/git.ts +1 -1
  154. package/src/sync/layout.ts +2 -2
  155. package/src/sync/stamp.ts +1 -1
  156. package/src/sync/workflow.ts +1 -1
  157. package/src/targets/validate.ts +2 -2
  158. package/src/tasks/answers.ts +1 -1
  159. package/src/tasks/archive.ts +1 -1
  160. package/src/tasks/label.ts +1 -1
  161. package/src/tasks/reach.ts +1 -1
  162. package/src/tasks/validate.ts +38 -14
  163. package/src/teach/nav.ts +1 -1
  164. package/src/teach/workspace.ts +1 -1
  165. package/src/tooling/inject.ts +2 -2
  166. package/src/tooling/read.ts +1 -1
  167. package/src/tooling/scan.ts +1 -1
  168. package/src/tooling/verify-stack.ts +1 -1
  169. package/src/version/installed.ts +1 -1
  170. package/src/version/skew.ts +1 -1
  171. package/standards/board.md +3 -3
  172. package/standards/plan.md +13 -7
  173. package/tsconfig.json +1 -1
  174. /package/src/{cli-run.ts → cli/run.ts} +0 -0
  175. /package/src/{ui.ts → cli/ui.ts} +0 -0
  176. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  177. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
@@ -21,6 +21,8 @@ export interface Scrub {
21
21
  readonly begin: () => ScrubSession
22
22
  /** Holds a scrubbed number inside what the property accepts. */
23
23
  readonly clamp: (value: number) => number
24
+ /** Scales each pixel's move, for a value whose useful range is small. */
25
+ readonly step?: number
24
26
  }
25
27
 
26
28
  interface FieldProps {
@@ -120,8 +122,11 @@ export function Field({
120
122
  const moved = event.clientX - held.lastX
121
123
  held.lastX = event.clientX
122
124
  held.value = scrub.clamp(
123
- Math.round((held.value + moved * scrubStep(event.shiftKey)) * 10) /
124
- 10,
125
+ Math.round(
126
+ (held.value +
127
+ moved * scrubStep(event.shiftKey) * (scrub.step ?? 1)) *
128
+ 10,
129
+ ) / 10,
125
130
  )
126
131
  const shown = String(held.value)
127
132
  if (input.current) input.current.value = shown
@@ -177,6 +182,60 @@ export function Field({
177
182
  )
178
183
  }
179
184
 
185
+ interface SelectFieldProps {
186
+ readonly label: string
187
+ readonly glyph: string
188
+ /** Each option's value, which is also what it shows. */
189
+ readonly options: readonly string[]
190
+ readonly value: string
191
+ readonly isBusy: boolean
192
+ readonly isWide?: boolean
193
+ readonly onCommit: (value: string) => void
194
+ }
195
+
196
+ /**
197
+ * A value picked from a fixed list, drawn like a field with a chevron. The
198
+ * select is native, so its list opens above the panel's clipping. A current
199
+ * value outside the list stays as an option, so it shows rather than blanks.
200
+ */
201
+ export function SelectField({
202
+ label,
203
+ glyph,
204
+ options,
205
+ value,
206
+ isBusy,
207
+ isWide,
208
+ onCommit,
209
+ }: SelectFieldProps): JSX.Element {
210
+ const shown = options.includes(value) ? options : [...options, value]
211
+ return (
212
+ <div class={fieldClass(isWide, 'is-select')}>
213
+ <span class="glyph" aria-hidden="true" title={label}>
214
+ {glyph}
215
+ </span>
216
+ <select
217
+ class="glyph-field-input glyph-select"
218
+ aria-label={label}
219
+ value={value}
220
+ disabled={isBusy}
221
+ onChange={(event) => {
222
+ const picked = event.currentTarget.value
223
+ if (picked !== value) onCommit(picked)
224
+ }}
225
+ >
226
+ {shown.map((option) => (
227
+ <option key={option} value={option}>
228
+ {option}
229
+ </option>
230
+ ))}
231
+ </select>
232
+ <svg class="select-chevron" viewBox="0 0 16 16" aria-hidden="true">
233
+ <path d="M4.5 6.5 8 10l3.5-3.5" />
234
+ </svg>
235
+ </div>
236
+ )
237
+ }
238
+
180
239
  interface ReadOnlyFieldProps {
181
240
  readonly label: string
182
241
  readonly glyph: string
@@ -679,3 +679,81 @@
679
679
  height: 14px;
680
680
  fill: currentColor;
681
681
  }
682
+
683
+ /*
684
+ * A value picked from a fixed list. The native select draws its value as the
685
+ * field's text and the chevron sits over its right edge, so the platform list
686
+ * opens on press and the field keeps the height of every other one.
687
+ */
688
+ .glyph-field.is-select {
689
+ position: relative;
690
+ }
691
+
692
+ .glyph-select {
693
+ appearance: none;
694
+ padding-right: 22px;
695
+ cursor: pointer;
696
+ }
697
+
698
+ .select-chevron {
699
+ position: absolute;
700
+ right: 6px;
701
+ width: 12px;
702
+ height: 12px;
703
+ fill: none;
704
+ stroke: var(--color-muted);
705
+ stroke-width: 1.5;
706
+ pointer-events: none;
707
+ }
708
+
709
+ /* A raw marker inside a field sits before its menu, clear of the value. */
710
+ .glyph-field .raw {
711
+ padding: 0 2px 0 var(--space-xs);
712
+ }
713
+
714
+ /* Mutually exclusive choices for one value, drawn as one field split evenly. */
715
+ .segmented {
716
+ display: grid;
717
+ grid-template-columns: repeat(4, minmax(0, 1fr));
718
+ gap: 2px;
719
+ min-height: 24px;
720
+ padding: 1px;
721
+ border: 1px solid var(--color-border);
722
+ border-radius: var(--radius-action);
723
+ background: var(--color-surface);
724
+ }
725
+
726
+ .segment {
727
+ display: grid;
728
+ place-items: center;
729
+ min-height: 22px;
730
+ padding: 0;
731
+ border: 0;
732
+ border-radius: var(--radius-action);
733
+ background: transparent;
734
+ color: var(--color-muted);
735
+ cursor: pointer;
736
+ }
737
+
738
+ .segment:hover {
739
+ color: var(--color-text);
740
+ }
741
+
742
+ .segment[aria-pressed='true'] {
743
+ background: var(--color-chrome);
744
+ color: var(--color-text);
745
+ }
746
+
747
+ .segment:disabled {
748
+ opacity: 0.5;
749
+ cursor: not-allowed;
750
+ }
751
+
752
+ .segment svg {
753
+ width: 14px;
754
+ height: 14px;
755
+ fill: none;
756
+ stroke: currentColor;
757
+ stroke-width: 1.5;
758
+ stroke-linecap: round;
759
+ }
@@ -0,0 +1,109 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { Field, type Scrub } from '@/canvas/client/inspector/field'
4
+
5
+ /** The menu entry that hands the value back to the field for typing. */
6
+ const RAW = ''
7
+
8
+ interface TokenSelectProps {
9
+ readonly label: string
10
+ readonly glyph: string
11
+ /** The value as the panel shows it, a token's name where one stands. */
12
+ readonly initial: string
13
+ /** One token group's names, in the order the token sheet sorts them. */
14
+ readonly tokens: readonly string[]
15
+ /** The token the element names, where it names one. */
16
+ readonly token?: string
17
+ readonly isRaw: boolean
18
+ readonly isBusy: boolean
19
+ readonly isWide?: boolean
20
+ readonly placeholder?: string
21
+ /** Takes a typed value as typed. */
22
+ readonly onCommit: (value: string) => void
23
+ /** Takes the name of a token picked from the menu or typed whole. */
24
+ readonly onPick: (name: string) => void
25
+ readonly scrub?: Scrub
26
+ }
27
+
28
+ /**
29
+ * A value that takes a token or a typed literal. The menu lists the group's
30
+ * tokens first and a raw value last, which returns focus to the field, so a
31
+ * token is one pick away and a literal is still one keystroke. A group with no
32
+ * tokens leaves the field alone, with no menu and no raw marker.
33
+ */
34
+ export function TokenSelect({
35
+ label,
36
+ glyph,
37
+ initial,
38
+ tokens,
39
+ token,
40
+ isRaw,
41
+ isBusy,
42
+ isWide,
43
+ placeholder,
44
+ onCommit,
45
+ onPick,
46
+ scrub,
47
+ }: TokenSelectProps): JSX.Element {
48
+ const hasTokens = tokens.length > 0
49
+ return (
50
+ <Field
51
+ label={label}
52
+ glyph={glyph}
53
+ initial={initial}
54
+ isBusy={isBusy}
55
+ isWide={isWide}
56
+ placeholder={placeholder}
57
+ onCommit={(typed) => {
58
+ if (tokens.includes(typed)) onPick(typed)
59
+ else onCommit(typed)
60
+ }}
61
+ scrub={scrub}
62
+ trailing={
63
+ hasTokens ? (
64
+ <>
65
+ {isRaw ? (
66
+ <span
67
+ class="raw"
68
+ title="Set as a raw value, so it will not follow the theme. Pick a token to fix it"
69
+ >
70
+ raw
71
+ </span>
72
+ ) : null}
73
+ <span class="size-mode">
74
+ <select
75
+ class="size-mode-select"
76
+ aria-label={`${label} tokens`}
77
+ value={token ?? RAW}
78
+ disabled={isBusy}
79
+ onChange={(event) => {
80
+ const picked = event.currentTarget.value
81
+ if (picked === RAW) {
82
+ event.currentTarget
83
+ .closest('.glyph-field')
84
+ ?.querySelector<HTMLInputElement>('.glyph-field-input')
85
+ ?.focus()
86
+ } else if (picked !== token) onPick(picked)
87
+ }}
88
+ >
89
+ {tokens.map((name) => (
90
+ <option key={name} value={name}>
91
+ {name}
92
+ </option>
93
+ ))}
94
+ <option value={RAW}>Raw value</option>
95
+ </select>
96
+ <svg
97
+ class="size-mode-chevron"
98
+ viewBox="0 0 16 16"
99
+ aria-hidden="true"
100
+ >
101
+ <path d="M4.5 6.5 8 10l3.5-3.5" />
102
+ </svg>
103
+ </span>
104
+ </>
105
+ ) : null
106
+ }
107
+ />
108
+ )
109
+ }
@@ -4,6 +4,9 @@
4
4
  * back through `toCssValue`, so rounding never writes a value nobody typed.
5
5
  */
6
6
 
7
+ /** A length that runs below zero, so a bare number is pixels and unclamped. */
8
+ const SIGNED_LENGTHS = new Set(['letter-spacing'])
9
+
7
10
  /** Properties whose bare numbers are pixel lengths. */
8
11
  const LENGTHS = new Set([
9
12
  'width',
@@ -26,6 +29,8 @@ const SIZE_MODES: Readonly<Record<string, string>> = {
26
29
  }
27
30
 
28
31
  const PX = /^(-?\d*\.?\d+)px$/
32
+ const EM = /^(-?\d*\.?\d+)em$/
33
+ const NUMBER = /^-?\d*\.?\d+$/
29
34
 
30
35
  function formatPx(token: string): string {
31
36
  const match = token.match(PX)
@@ -45,6 +50,11 @@ export function isNoFill(value: string): boolean {
45
50
  export function displayValue(property: string, raw: string): string {
46
51
  const value = raw.trim()
47
52
  if (property === 'gap' && (value === '' || value === 'normal')) return ''
53
+ if (property === 'letter-spacing') {
54
+ if (value === 'normal') return ''
55
+ const em = value.match(EM)
56
+ if (em) return `${Math.round(Number(em[1]) * 1000) / 1000}em`
57
+ }
48
58
  if (property === 'background-color' && isNoFill(value)) return ''
49
59
  if (property === 'opacity' && value !== '')
50
60
  return `${Math.round(Number(value) * 100)}%`
@@ -61,9 +71,13 @@ export function displayValue(property: string, raw: string): string {
61
71
 
62
72
  export function toCssValue(property: string, typed: string): string {
63
73
  const value = typed.trim()
74
+ /* A line height has no auto keyword, so the field's Auto is its normal. */
75
+ if (property === 'line-height' && value.toLowerCase() === 'auto') {
76
+ return 'normal'
77
+ }
64
78
  if (value === 'Auto' || value === 'Normal') return value.toLowerCase()
65
79
  if (SIZES.has(property) && SIZE_MODES[value]) return SIZE_MODES[value]
66
- if (!LENGTHS.has(property)) return value
80
+ if (!LENGTHS.has(property) && !SIGNED_LENGTHS.has(property)) return value
67
81
  return value
68
82
  .split(/\s+/)
69
83
  .map((token) => (/^-?\d*\.?\d+$/.test(token) ? `${token}px` : token))
@@ -92,10 +106,68 @@ export function sharedRadius(corners: readonly string[]): string | undefined {
92
106
  */
93
107
  export function clampScrub(property: string, value: number): number {
94
108
  if (property === 'font-weight') return Math.min(1000, Math.max(1, value))
95
- if (LENGTHS.has(property)) return Math.max(0, value)
109
+ if (LENGTHS.has(property) || property === 'line-height') {
110
+ return Math.max(0, value)
111
+ }
112
+ return value
113
+ }
114
+
115
+ /**
116
+ * A line height as the panel shows it: a ratio of the font size, the way a
117
+ * unitless value writes it. A browser computes a unitless value to pixels, so
118
+ * a pixel value reads back as its ratio when the font size is in pixels too.
119
+ */
120
+ export function lineHeightValue(raw: string, fontSize: string): string {
121
+ const value = raw.trim()
122
+ if (value === 'normal') return 'Auto'
123
+ const ratio = (number: number): string =>
124
+ String(Math.round(number * 100) / 100)
125
+ if (NUMBER.test(value)) return ratio(Number(value))
126
+ const height = value.match(PX)
127
+ const size = fontSize.trim().match(PX)
128
+ if (height && size && Number(size[1]) > 0) {
129
+ return ratio(Number(height[1]) / Number(size[1]))
130
+ }
131
+ return value
132
+ }
133
+
134
+ /** The first family of a stack, unquoted, which is the one the field names. */
135
+ export function firstFamily(stack: string): string {
136
+ const value = stack.trim()
137
+ if (value.startsWith('var(')) return value
138
+ const [first = ''] = value.split(',')
139
+ return first.trim().replace(/^(["'])(.*)\1$/, '$2')
140
+ }
141
+
142
+ /**
143
+ * Text alignment by the side it lands on, so the group presses the side the
144
+ * text paints on. The logical keywords turn on the element's direction.
145
+ */
146
+ export function textAlignOf(computed: string, direction: string): string {
147
+ const value = computed.trim()
148
+ const isRtl = direction.trim() === 'rtl'
149
+ if (value === 'start') return isRtl ? 'right' : 'left'
150
+ if (value === 'end') return isRtl ? 'left' : 'right'
96
151
  return value
97
152
  }
98
153
 
154
+ /**
155
+ * A value as the writer takes it. A whole declaration pasted from a
156
+ * stylesheet keeps its value alone, since the writer refuses the semicolon.
157
+ */
158
+ export function declarationValue(property: string, typed: string): string {
159
+ const value = typed.trim().replace(/;\s*$/, '')
160
+ const prefix = `${property}:`
161
+ return value.toLowerCase().startsWith(prefix)
162
+ ? value.slice(prefix.length).trim()
163
+ : value
164
+ }
165
+
166
+ /** The token a value names when it is a bare `var()` and nothing else. */
167
+ export function tokenOf(value: string): string | undefined {
168
+ return value.trim().match(VAR)?.[1]
169
+ }
170
+
99
171
  /** Units a scrub moves for each pixel the pointer travels. */
100
172
  export function scrubStep(isCoarse: boolean): number {
101
173
  return isCoarse ? 10 : 1
@@ -8,16 +8,23 @@ import {
8
8
  Field,
9
9
  ReadOnlyField,
10
10
  type Scrub,
11
+ SelectField,
11
12
  } from '@/canvas/client/inspector/field'
12
13
  import { Section } from '@/canvas/client/inspector/section'
13
14
  import { SizeField } from '@/canvas/client/inspector/size-field'
15
+ import { TokenSelect } from '@/canvas/client/inspector/token-select'
14
16
  import {
15
17
  clampScrub,
18
+ declarationValue,
16
19
  displayValue,
20
+ firstFamily,
21
+ lineHeightValue,
17
22
  opacityToCss,
18
23
  parseColor,
19
24
  sharedRadius,
25
+ textAlignOf,
20
26
  toCssValue,
27
+ tokenOf,
21
28
  } from '@/canvas/client/inspector/values'
22
29
  import {
23
30
  currentPage,
@@ -27,6 +34,7 @@ import {
27
34
  frameDocuments,
28
35
  frameKey,
29
36
  frameVersions,
37
+ newStep,
30
38
  pendingEdit,
31
39
  savedEdit,
32
40
  selectedElement,
@@ -34,6 +42,7 @@ import {
34
42
  tokens,
35
43
  writeError,
36
44
  } from '@/canvas/client/state'
45
+ import type { TokenKind } from '@/canvas/tokens'
37
46
  import '@/canvas/client/inspector/fields.css'
38
47
 
39
48
  /** Elements the parser never gives content, so they hold no text to edit. */
@@ -112,11 +121,26 @@ const CORNER_FIELDS: readonly StyleField[] = [
112
121
  const FLEX_DISPLAYS = new Set(['flex', 'inline-flex'])
113
122
  const GRID_DISPLAYS = new Set(['grid', 'inline-grid'])
114
123
 
115
- const TYPE_FIELDS: readonly StyleField[] = [
116
- { label: 'size', glyph: 'Size', property: 'font-size' },
117
- { label: 'weight', glyph: 'Wt', property: 'font-weight' },
124
+ const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900']
125
+
126
+ /** Line height and letter spacing move in tenths, since their range is small. */
127
+ const SPACING_FIELDS: readonly StyleField[] = [
128
+ { label: 'line height', glyph: 'LH', property: 'line-height' },
129
+ {
130
+ label: 'letter spacing',
131
+ glyph: 'LS',
132
+ property: 'letter-spacing',
133
+ placeholder: '0',
134
+ },
118
135
  ]
119
136
 
137
+ const TEXT_ALIGNS = [
138
+ { value: 'left', path: 'M2.5 4h11M2.5 8h7M2.5 12h9' },
139
+ { value: 'center', path: 'M2.5 4h11M4.5 8h7M3.5 12h9' },
140
+ { value: 'right', path: 'M2.5 4h11M6.5 8h7M4.5 12h9' },
141
+ { value: 'justify', path: 'M2.5 4h11M2.5 8h11M2.5 12h11' },
142
+ ] as const
143
+
120
144
  const FILL_FIELDS = [
121
145
  { label: 'color', property: 'color' },
122
146
  { label: 'background', property: 'background-color' },
@@ -217,17 +241,25 @@ function ElementFields({
217
241
  }
218
242
  /*
219
243
  * The writer takes one property an edit, so a control setting two posts
220
- * them in turn and stops at the first refusal. Each carries the hash the
221
- * last answered with in its own address, since a frame reloading between
222
- * the two clears the stored one.
244
+ * them in turn and stops at the first refusal, under one step so they undo
245
+ * as one. Each carries the hash the last answered with in its own address,
246
+ * since a frame reloading between the two clears the stored one.
223
247
  */
224
248
  const commitAll = async (
225
249
  changes: readonly (readonly [string, string])[],
226
250
  ): Promise<void> => {
251
+ const step = newStep()
227
252
  let current = address
228
253
  for (const [property, value] of changes) {
229
254
  if (!current) return
230
- const written = await editElement(frameRef, key, current, property, value)
255
+ const written = await editElement(
256
+ frameRef,
257
+ key,
258
+ current,
259
+ property,
260
+ value,
261
+ step,
262
+ )
231
263
  if (!written) return
232
264
  if (written.hash) current = { ...current, hash: written.hash }
233
265
  }
@@ -241,20 +273,29 @@ function ElementFields({
241
273
  const isFlex = FLEX_DISPLAYS.has(display)
242
274
  const isWrapped = computedValue(node, 'flex-wrap').trim() === 'wrap'
243
275
  const rect = node.getBoundingClientRect()
244
- const font = currentValue(node, 'font-family')
245
276
  const text = node.textContent ?? ''
246
- const colorTokens =
277
+ const groupTokens = (kind: TokenKind): readonly string[] =>
247
278
  tokens.value?.groups
248
- ?.find((group) => group.kind === 'color')
279
+ ?.find((group) => group.kind === kind)
249
280
  ?.tokens.map((token) => token.name) ?? []
281
+ const colorTokens = groupTokens('color')
282
+ const family = currentValue(node, 'font-family')
283
+ const familyToken = tokenOf(family)
284
+ const size = currentValue(node, 'font-size')
285
+ const sizeToken = tokenOf(size)
286
+ const textAlign = textAlignOf(
287
+ computedValue(node, 'text-align'),
288
+ computedValue(node, 'direction'),
289
+ )
250
290
 
251
291
  /**
252
292
  * Previews into the frame's own inline style and writes through the same
253
293
  * edit as typing. A reload mid-drag replaces the document, so a release
254
294
  * against one no longer on screen posts nothing.
255
295
  */
256
- const scrubOf = (property: string): Scrub => ({
296
+ const scrubOf = (property: string, step?: number): Scrub => ({
257
297
  clamp: (value) => clampScrub(property, value),
298
+ step,
258
299
  begin: () => {
259
300
  const original = inlineValue(node, property)
260
301
  const style = (node as HTMLElement).style
@@ -422,10 +463,88 @@ function ElementFields({
422
463
  )}
423
464
  </Section>
424
465
  <Section title="Typography">
425
- {TYPE_FIELDS.map(styleField)}
426
- {font ? (
427
- <ReadOnlyField label="font" glyph="Font" value={font} isWide />
428
- ) : null}
466
+ <TokenSelect
467
+ label="family"
468
+ glyph="Aa"
469
+ initial={familyToken ?? firstFamily(family)}
470
+ tokens={groupTokens('font-family')}
471
+ token={familyToken}
472
+ isRaw={isRawInline(node, 'font-family')}
473
+ isBusy={isBusy}
474
+ isWide
475
+ onCommit={(typed) =>
476
+ commit('font-family')(declarationValue('font-family', typed))
477
+ }
478
+ onPick={(name) => commit('font-family')(`var(${name})`)}
479
+ />
480
+ <TokenSelect
481
+ label="size"
482
+ glyph="Size"
483
+ initial={sizeToken ?? displayValue('font-size', size)}
484
+ tokens={groupTokens('font-size')}
485
+ token={sizeToken}
486
+ isRaw={isRawInline(node, 'font-size')}
487
+ isBusy={isBusy}
488
+ onCommit={(typed) =>
489
+ commit('font-size')(toCssValue('font-size', typed))
490
+ }
491
+ onPick={(name) => commit('font-size')(`var(${name})`)}
492
+ scrub={'style' in node ? scrubOf('font-size') : undefined}
493
+ />
494
+ <SelectField
495
+ label="weight"
496
+ glyph="Wt"
497
+ options={WEIGHTS}
498
+ value={currentValue(node, 'font-weight').trim()}
499
+ isBusy={isBusy}
500
+ onCommit={commit('font-weight')}
501
+ />
502
+ {SPACING_FIELDS.map((field) => (
503
+ <Field
504
+ key={field.property}
505
+ label={field.label}
506
+ glyph={field.glyph}
507
+ initial={
508
+ field.property === 'line-height'
509
+ ? lineHeightValue(
510
+ currentValue(node, 'line-height'),
511
+ computedValue(node, 'font-size'),
512
+ )
513
+ : displayValue(
514
+ field.property,
515
+ currentValue(node, field.property),
516
+ )
517
+ }
518
+ isBusy={isBusy}
519
+ placeholder={field.placeholder}
520
+ onCommit={(typed) =>
521
+ commit(field.property)(toCssValue(field.property, typed))
522
+ }
523
+ scrub={'style' in node ? scrubOf(field.property, 0.1) : undefined}
524
+ />
525
+ ))}
526
+ <div class="segmented is-wide" role="group" aria-label="text alignment">
527
+ {TEXT_ALIGNS.map((align) => (
528
+ <button
529
+ key={align.value}
530
+ type="button"
531
+ class="segment"
532
+ aria-label={`Align ${align.value}`}
533
+ title={`Align ${align.value}`}
534
+ aria-pressed={textAlign === align.value}
535
+ disabled={isBusy}
536
+ onClick={() => {
537
+ if (textAlign !== align.value) {
538
+ commit('text-align')(align.value)
539
+ }
540
+ }}
541
+ >
542
+ <svg viewBox="0 0 16 16" aria-hidden="true">
543
+ <path d={align.path} />
544
+ </svg>
545
+ </button>
546
+ ))}
547
+ </div>
429
548
  </Section>
430
549
  <Section title="Fill">
431
550
  {FILL_FIELDS.map((field) => {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The canvas key layer: one map from a key event to a surface action, which
3
+ * the surface and every loaded frame document call. It sees only what the
4
+ * surface forwards, so a key typed into a panel field never reaches it.
5
+ */
6
+
7
+ export type SurfaceAction =
8
+ | 'tool-move'
9
+ | 'tool-pan'
10
+ | 'zoom-in'
11
+ | 'zoom-out'
12
+ | 'zoom-fit'
13
+ | 'pan-hold'
14
+ | 'pan-release'
15
+ | 'undo'
16
+ | 'redo'
17
+ | 'panels-toggle'
18
+
19
+ /** The fields of a `KeyboardEvent` the map reads, so a test needs no DOM. */
20
+ export interface KeyInput {
21
+ readonly type: string
22
+ readonly key: string
23
+ readonly code: string
24
+ readonly shiftKey: boolean
25
+ readonly ctrlKey: boolean
26
+ readonly metaKey: boolean
27
+ readonly altKey: boolean
28
+ }
29
+
30
+ const PLAIN_KEYS: Readonly<Record<string, SurfaceAction | undefined>> = {
31
+ v: 'tool-move',
32
+ h: 'tool-pan',
33
+ '=': 'zoom-in',
34
+ '+': 'zoom-in',
35
+ '-': 'zoom-out',
36
+ ' ': 'pan-hold',
37
+ }
38
+
39
+ /**
40
+ * Undo and redo are the one exception to the browser owning a modifier: Ctrl
41
+ * or Cmd with Z, with Shift to redo, and Ctrl with Y. Any other Cmd, Ctrl, or
42
+ * Alt combination is the browser's or the system's, so it returns nothing.
43
+ */
44
+ function historyAction(event: KeyInput): SurfaceAction | undefined {
45
+ if (event.altKey) return undefined
46
+ const key = event.key.toLowerCase()
47
+ if (key === 'z') return event.shiftKey ? 'redo' : 'undo'
48
+ if (key === 'y' && event.ctrlKey && !event.shiftKey) return 'redo'
49
+ return undefined
50
+ }
51
+
52
+ /**
53
+ * Space coming up releases whatever is held with it, or a modifier pressed
54
+ * mid-pan would leave the pan stuck. Shift and the 1 key fits, and the
55
+ * backslash key toggles the panels, by `code`, since the character each
56
+ * gives differs by layout.
57
+ */
58
+ export function surfaceAction(event: KeyInput): SurfaceAction | undefined {
59
+ if (event.type === 'keyup') {
60
+ return event.key === ' ' ? 'pan-release' : undefined
61
+ }
62
+ if (event.type !== 'keydown') return undefined
63
+ if (event.ctrlKey || event.metaKey) return historyAction(event)
64
+ if (event.altKey) return undefined
65
+ if (event.shiftKey) {
66
+ if (event.code === 'Digit1') return 'zoom-fit'
67
+ return event.key === '+' ? 'zoom-in' : undefined
68
+ }
69
+ if (event.code === 'Backslash') return 'panels-toggle'
70
+ return PLAIN_KEYS[event.key.toLowerCase()]
71
+ }
@@ -174,7 +174,11 @@ function Tabs(): JSX.Element {
174
174
 
175
175
  export function PagesPanel(props: PagesPanelProps): JSX.Element {
176
176
  return (
177
- <nav class="panel panel-left" aria-label="Pages and frames">
177
+ <nav
178
+ id="pages-panel"
179
+ class="panel panel-left"
180
+ aria-label="Pages and frames"
181
+ >
178
182
  <header class="panel-head">
179
183
  <span class="brand">Canvas</span>
180
184
  <ThemeToggle />