@erclx/canon 5.27.0 → 5.28.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 (139) 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 +13 -3
  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 +4 -4
  31. package/docs/agents/gate.md +3 -1
  32. package/docs/agents/index.md +2 -1
  33. package/docs/agents/pr-evidence.md +9 -8
  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/inspector/field.tsx +61 -2
  46. package/src/canvas/client/inspector/fields.css +78 -0
  47. package/src/canvas/client/inspector/token-select.tsx +109 -0
  48. package/src/canvas/client/inspector/values.ts +74 -2
  49. package/src/canvas/client/inspector.tsx +121 -11
  50. package/src/canvas/client/keys.ts +54 -0
  51. package/src/canvas/client/selection.tsx +3 -1
  52. package/src/canvas/client/shell.css +108 -2
  53. package/src/canvas/client/state.ts +96 -5
  54. package/src/canvas/client/surface.tsx +194 -17
  55. package/src/canvas/client/tools.tsx +64 -0
  56. package/src/canvas/content.ts +1 -1
  57. package/src/canvas/edit.ts +12 -2
  58. package/src/canvas/tokens.ts +1 -1
  59. package/src/claude/seeds.ts +2 -2
  60. package/src/claude/skills-reach.ts +1 -1
  61. package/src/commands/canvas.ts +1 -1
  62. package/src/commands/claude.ts +3 -3
  63. package/src/commands/design.ts +3 -3
  64. package/src/commands/docs.ts +1 -1
  65. package/src/commands/feedback.ts +2 -2
  66. package/src/commands/gate.ts +9 -2
  67. package/src/commands/gov.ts +2 -2
  68. package/src/commands/init.ts +1 -1
  69. package/src/commands/migrate.ts +1 -1
  70. package/src/commands/pr.ts +183 -7
  71. package/src/commands/sandbox.ts +10 -13
  72. package/src/commands/slides.ts +1 -1
  73. package/src/commands/sync.ts +1 -1
  74. package/src/commands/tasks.ts +19 -2
  75. package/src/commands/tooling.ts +1 -1
  76. package/src/commands/transcripts.ts +1 -1
  77. package/src/commands/upgrade.ts +1 -1
  78. package/src/commands/wiki.ts +1 -1
  79. package/src/context/architecture.ts +1 -1
  80. package/src/context/citations.ts +2 -2
  81. package/src/context/classify/extract.ts +1 -1
  82. package/src/context/folders.ts +2 -2
  83. package/src/context/requirements.ts +1 -1
  84. package/src/docs/read.ts +1 -1
  85. package/src/exec.ts +1 -1
  86. package/src/feedback/github.ts +1 -1
  87. package/src/gate/measures.ts +1 -1
  88. package/src/gate/sequencer.ts +55 -3
  89. package/src/gate/stages.ts +8 -0
  90. package/src/gate/suite-lock.ts +214 -0
  91. package/src/intake/folder.ts +1 -1
  92. package/src/labels/map.ts +1 -1
  93. package/src/labels/phase.ts +3 -3
  94. package/src/migrate/record-layout.ts +1 -1
  95. package/src/migrate/records.ts +11 -11
  96. package/src/migrate/scratch-evidence.ts +1 -1
  97. package/src/migrate/surface-roots.ts +6 -6
  98. package/src/pr/evidence.ts +154 -16
  99. package/src/pr/tick.ts +56 -0
  100. package/src/process/harness.ts +1 -1
  101. package/src/records/backup.ts +1 -1
  102. package/src/records/ordinal.ts +1 -1
  103. package/src/records/prune.ts +1 -1
  104. package/src/records/size.ts +1 -1
  105. package/src/records/stale-canonical.ts +1 -1
  106. package/src/records/stale.ts +1 -1
  107. package/src/records/validate.ts +2 -2
  108. package/src/{project-root.ts → roots/project.ts} +1 -1
  109. package/src/{record-root.ts → roots/record.ts} +1 -1
  110. package/src/sandbox/coverage.ts +11 -12
  111. package/src/sandbox/equivalence.ts +57 -26
  112. package/src/sandbox/hooks.ts +1 -1
  113. package/src/sandbox/provision.ts +78 -28
  114. package/src/sandbox/scenario.ts +588 -0
  115. package/src/sessions/live.ts +4 -1
  116. package/src/sessions/transfer/bundle.ts +1 -1
  117. package/src/slides/decks.ts +1 -1
  118. package/src/standards/read.ts +1 -1
  119. package/src/sync/engine.ts +1 -1
  120. package/src/sync/layout.ts +2 -2
  121. package/src/sync/stamp.ts +1 -1
  122. package/src/targets/validate.ts +1 -1
  123. package/src/tasks/answers.ts +1 -1
  124. package/src/tasks/archive.ts +1 -1
  125. package/src/tasks/label.ts +1 -1
  126. package/src/tasks/reach.ts +1 -1
  127. package/src/tasks/validate.ts +38 -14
  128. package/src/teach/nav.ts +1 -1
  129. package/src/teach/workspace.ts +1 -1
  130. package/src/tooling/inject.ts +1 -1
  131. package/src/tooling/read.ts +1 -1
  132. package/src/tooling/scan.ts +1 -1
  133. package/src/version/installed.ts +1 -1
  134. package/src/version/skew.ts +1 -1
  135. package/standards/board.md +3 -3
  136. package/standards/plan.md +13 -7
  137. package/tsconfig.json +1 -1
  138. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  139. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
@@ -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,
@@ -34,6 +41,7 @@ import {
34
41
  tokens,
35
42
  writeError,
36
43
  } from '@/canvas/client/state'
44
+ import type { TokenKind } from '@/canvas/tokens'
37
45
  import '@/canvas/client/inspector/fields.css'
38
46
 
39
47
  /** Elements the parser never gives content, so they hold no text to edit. */
@@ -112,11 +120,26 @@ const CORNER_FIELDS: readonly StyleField[] = [
112
120
  const FLEX_DISPLAYS = new Set(['flex', 'inline-flex'])
113
121
  const GRID_DISPLAYS = new Set(['grid', 'inline-grid'])
114
122
 
115
- const TYPE_FIELDS: readonly StyleField[] = [
116
- { label: 'size', glyph: 'Size', property: 'font-size' },
117
- { label: 'weight', glyph: 'Wt', property: 'font-weight' },
123
+ const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900']
124
+
125
+ /** Line height and letter spacing move in tenths, since their range is small. */
126
+ const SPACING_FIELDS: readonly StyleField[] = [
127
+ { label: 'line height', glyph: 'LH', property: 'line-height' },
128
+ {
129
+ label: 'letter spacing',
130
+ glyph: 'LS',
131
+ property: 'letter-spacing',
132
+ placeholder: '0',
133
+ },
118
134
  ]
119
135
 
136
+ const TEXT_ALIGNS = [
137
+ { value: 'left', path: 'M2.5 4h11M2.5 8h7M2.5 12h9' },
138
+ { value: 'center', path: 'M2.5 4h11M4.5 8h7M3.5 12h9' },
139
+ { value: 'right', path: 'M2.5 4h11M6.5 8h7M4.5 12h9' },
140
+ { value: 'justify', path: 'M2.5 4h11M2.5 8h11M2.5 12h11' },
141
+ ] as const
142
+
120
143
  const FILL_FIELDS = [
121
144
  { label: 'color', property: 'color' },
122
145
  { label: 'background', property: 'background-color' },
@@ -241,20 +264,29 @@ function ElementFields({
241
264
  const isFlex = FLEX_DISPLAYS.has(display)
242
265
  const isWrapped = computedValue(node, 'flex-wrap').trim() === 'wrap'
243
266
  const rect = node.getBoundingClientRect()
244
- const font = currentValue(node, 'font-family')
245
267
  const text = node.textContent ?? ''
246
- const colorTokens =
268
+ const groupTokens = (kind: TokenKind): readonly string[] =>
247
269
  tokens.value?.groups
248
- ?.find((group) => group.kind === 'color')
270
+ ?.find((group) => group.kind === kind)
249
271
  ?.tokens.map((token) => token.name) ?? []
272
+ const colorTokens = groupTokens('color')
273
+ const family = currentValue(node, 'font-family')
274
+ const familyToken = tokenOf(family)
275
+ const size = currentValue(node, 'font-size')
276
+ const sizeToken = tokenOf(size)
277
+ const textAlign = textAlignOf(
278
+ computedValue(node, 'text-align'),
279
+ computedValue(node, 'direction'),
280
+ )
250
281
 
251
282
  /**
252
283
  * Previews into the frame's own inline style and writes through the same
253
284
  * edit as typing. A reload mid-drag replaces the document, so a release
254
285
  * against one no longer on screen posts nothing.
255
286
  */
256
- const scrubOf = (property: string): Scrub => ({
287
+ const scrubOf = (property: string, step?: number): Scrub => ({
257
288
  clamp: (value) => clampScrub(property, value),
289
+ step,
258
290
  begin: () => {
259
291
  const original = inlineValue(node, property)
260
292
  const style = (node as HTMLElement).style
@@ -422,10 +454,88 @@ function ElementFields({
422
454
  )}
423
455
  </Section>
424
456
  <Section title="Typography">
425
- {TYPE_FIELDS.map(styleField)}
426
- {font ? (
427
- <ReadOnlyField label="font" glyph="Font" value={font} isWide />
428
- ) : null}
457
+ <TokenSelect
458
+ label="family"
459
+ glyph="Aa"
460
+ initial={familyToken ?? firstFamily(family)}
461
+ tokens={groupTokens('font-family')}
462
+ token={familyToken}
463
+ isRaw={isRawInline(node, 'font-family')}
464
+ isBusy={isBusy}
465
+ isWide
466
+ onCommit={(typed) =>
467
+ commit('font-family')(declarationValue('font-family', typed))
468
+ }
469
+ onPick={(name) => commit('font-family')(`var(${name})`)}
470
+ />
471
+ <TokenSelect
472
+ label="size"
473
+ glyph="Size"
474
+ initial={sizeToken ?? displayValue('font-size', size)}
475
+ tokens={groupTokens('font-size')}
476
+ token={sizeToken}
477
+ isRaw={isRawInline(node, 'font-size')}
478
+ isBusy={isBusy}
479
+ onCommit={(typed) =>
480
+ commit('font-size')(toCssValue('font-size', typed))
481
+ }
482
+ onPick={(name) => commit('font-size')(`var(${name})`)}
483
+ scrub={'style' in node ? scrubOf('font-size') : undefined}
484
+ />
485
+ <SelectField
486
+ label="weight"
487
+ glyph="Wt"
488
+ options={WEIGHTS}
489
+ value={currentValue(node, 'font-weight').trim()}
490
+ isBusy={isBusy}
491
+ onCommit={commit('font-weight')}
492
+ />
493
+ {SPACING_FIELDS.map((field) => (
494
+ <Field
495
+ key={field.property}
496
+ label={field.label}
497
+ glyph={field.glyph}
498
+ initial={
499
+ field.property === 'line-height'
500
+ ? lineHeightValue(
501
+ currentValue(node, 'line-height'),
502
+ computedValue(node, 'font-size'),
503
+ )
504
+ : displayValue(
505
+ field.property,
506
+ currentValue(node, field.property),
507
+ )
508
+ }
509
+ isBusy={isBusy}
510
+ placeholder={field.placeholder}
511
+ onCommit={(typed) =>
512
+ commit(field.property)(toCssValue(field.property, typed))
513
+ }
514
+ scrub={'style' in node ? scrubOf(field.property, 0.1) : undefined}
515
+ />
516
+ ))}
517
+ <div class="segmented is-wide" role="group" aria-label="text alignment">
518
+ {TEXT_ALIGNS.map((align) => (
519
+ <button
520
+ key={align.value}
521
+ type="button"
522
+ class="segment"
523
+ aria-label={`Align ${align.value}`}
524
+ title={`Align ${align.value}`}
525
+ aria-pressed={textAlign === align.value}
526
+ disabled={isBusy}
527
+ onClick={() => {
528
+ if (textAlign !== align.value) {
529
+ commit('text-align')(align.value)
530
+ }
531
+ }}
532
+ >
533
+ <svg viewBox="0 0 16 16" aria-hidden="true">
534
+ <path d={align.path} />
535
+ </svg>
536
+ </button>
537
+ ))}
538
+ </div>
429
539
  </Section>
430
540
  <Section title="Fill">
431
541
  {FILL_FIELDS.map((field) => {
@@ -0,0 +1,54 @@
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
+
16
+ /** The fields of a `KeyboardEvent` the map reads, so a test needs no DOM. */
17
+ export interface KeyInput {
18
+ readonly type: string
19
+ readonly key: string
20
+ readonly code: string
21
+ readonly shiftKey: boolean
22
+ readonly ctrlKey: boolean
23
+ readonly metaKey: boolean
24
+ readonly altKey: boolean
25
+ }
26
+
27
+ const PLAIN_KEYS: Readonly<Record<string, SurfaceAction | undefined>> = {
28
+ v: 'tool-move',
29
+ h: 'tool-pan',
30
+ '=': 'zoom-in',
31
+ '+': 'zoom-in',
32
+ '-': 'zoom-out',
33
+ ' ': 'pan-hold',
34
+ }
35
+
36
+ /**
37
+ * Cmd, Ctrl, and Alt combinations are the browser's or the system's, so any
38
+ * of them held on a press returns nothing. Space coming up releases whatever
39
+ * is held with it, or a modifier pressed mid-pan would leave the pan stuck.
40
+ * Shift and the 1 key fits by `code`, since the character Shift gives on that
41
+ * key differs by layout.
42
+ */
43
+ export function surfaceAction(event: KeyInput): SurfaceAction | undefined {
44
+ if (event.type === 'keyup') {
45
+ return event.key === ' ' ? 'pan-release' : undefined
46
+ }
47
+ if (event.type !== 'keydown') return undefined
48
+ if (event.ctrlKey || event.metaKey || event.altKey) return undefined
49
+ if (event.shiftKey) {
50
+ if (event.code === 'Digit1') return 'zoom-fit'
51
+ return event.key === '+' ? 'zoom-in' : undefined
52
+ }
53
+ return PLAIN_KEYS[event.key.toLowerCase()]
54
+ }
@@ -1,7 +1,7 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
3
  import { useRef, useState } from 'preact/hooks'
4
- import { view } from '@/canvas/client/state'
4
+ import { shownTool, view } from '@/canvas/client/state'
5
5
 
6
6
  /** Screen pixels a press travels before it reads as a drag and not a click. */
7
7
  export const DRAG_THRESHOLD = 3
@@ -97,6 +97,8 @@ export function SelectionOverlay({
97
97
 
98
98
  const handlePointerDown = (corner: Corner) => (event: PointerEvent) => {
99
99
  if (event.button !== 0) return
100
+ /* A pan drag starting on a handle belongs to the surface behind it. */
101
+ if (shownTool.value === 'pan') return
100
102
  event.preventDefault()
101
103
  event.stopPropagation()
102
104
  const origin = measure()
@@ -217,6 +217,27 @@ code {
217
217
  cursor: grabbing;
218
218
  }
219
219
 
220
+ /* Inset, so the panels on either side never clip the ring. */
221
+ .surface:focus-visible {
222
+ outline-offset: -2px;
223
+ }
224
+
225
+ /*
226
+ * While panning, a frame takes no pointer, so a drag over one moves the view
227
+ * rather than picking an element, and the cursor reads the viewport's grab.
228
+ * The descendants are named too, since a selection handle sets its own
229
+ * pointer events and would otherwise take the drag as a resize. Nothing in
230
+ * the viewport starts a text selection while a pan drags across it.
231
+ */
232
+ .surface[data-tool='pan'] .viewport {
233
+ user-select: none;
234
+ }
235
+
236
+ .surface[data-tool='pan'] .frame,
237
+ .surface[data-tool='pan'] .frame * {
238
+ pointer-events: none;
239
+ }
240
+
220
241
  .plane {
221
242
  position: absolute;
222
243
  top: 0;
@@ -239,8 +260,9 @@ code {
239
260
  align-items: center;
240
261
  /*
241
262
  * Sized against the zoom, so the label reads at one screen size at any zoom.
242
- * A row too short for all three parts wraps the theme switch and then the
243
- * size onto lines the clip hides, so the name keeps the row longest.
263
+ * A row too short for every part wraps the theme switch, then the editing
264
+ * badge, then the size onto lines the clip hides, so the name keeps the row
265
+ * longest.
244
266
  */
245
267
  flex-wrap: wrap;
246
268
  align-content: flex-start;
@@ -273,6 +295,40 @@ code {
273
295
  font-variant-numeric: tabular-nums;
274
296
  }
275
297
 
298
+ /*
299
+ * Ahead of the theme switch, so a row too short for both wraps the switch
300
+ * first. The editing outline still marks the frame when the badge wraps too.
301
+ */
302
+ .frame-editing {
303
+ display: inline-flex;
304
+ align-items: center;
305
+ gap: calc(4px * var(--outline-scale, 1));
306
+ padding: calc(1px * var(--outline-scale, 1))
307
+ calc(6px * var(--outline-scale, 1));
308
+ border-radius: var(--radius-pill);
309
+ background: color-mix(in srgb, var(--color-success) 16%, transparent);
310
+ color: var(--color-success);
311
+ font-weight: 600;
312
+ }
313
+
314
+ .frame-editing:empty {
315
+ display: none;
316
+ }
317
+
318
+ .frame-editing::before {
319
+ content: '';
320
+ width: calc(6px * var(--outline-scale, 1));
321
+ height: calc(6px * var(--outline-scale, 1));
322
+ border-radius: 50%;
323
+ background: currentColor;
324
+ }
325
+
326
+ /* Dashed, so it reads apart from the solid accent of a selection. */
327
+ .frame[data-editing='true'] iframe {
328
+ outline: calc(2px * var(--outline-scale, 1)) dashed var(--color-success);
329
+ outline-offset: calc(2px * var(--outline-scale, 1));
330
+ }
331
+
276
332
  .frame-theme {
277
333
  margin-left: auto;
278
334
  padding: calc(1px * var(--outline-scale, 1))
@@ -425,6 +481,56 @@ code {
425
481
  background: var(--color-chrome);
426
482
  }
427
483
 
484
+ .tools {
485
+ position: absolute;
486
+ top: var(--space-md);
487
+ left: var(--space-md);
488
+ display: flex;
489
+ flex-direction: column;
490
+ gap: 2px;
491
+ padding: var(--space-xs);
492
+ border: 1px solid var(--color-border);
493
+ border-radius: var(--radius-panel);
494
+ background: var(--color-chrome);
495
+ }
496
+
497
+ .tool-button {
498
+ display: grid;
499
+ place-items: center;
500
+ width: 28px;
501
+ height: 28px;
502
+ padding: 0;
503
+ border: 0;
504
+ border-radius: var(--radius-action);
505
+ background: transparent;
506
+ color: var(--color-text-secondary);
507
+ cursor: pointer;
508
+ }
509
+
510
+ .tool-button svg {
511
+ width: 16px;
512
+ height: 16px;
513
+ fill: none;
514
+ stroke: currentColor;
515
+ stroke-width: 1.3;
516
+ stroke-linejoin: round;
517
+ stroke-linecap: round;
518
+ }
519
+
520
+ .tool-button[data-tool='move'] svg {
521
+ fill: currentColor;
522
+ }
523
+
524
+ .tool-button:hover {
525
+ color: var(--color-text);
526
+ background: var(--color-surface);
527
+ }
528
+
529
+ .tool-button[aria-pressed='true'] {
530
+ color: var(--color-text);
531
+ background: var(--color-border);
532
+ }
533
+
428
534
  .zoom {
429
535
  min-width: 3.5rem;
430
536
  text-align: center;