@erclx/canon 5.24.0 → 5.26.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 (185) hide show
  1. package/README.md +2 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  4. package/claude/skills/backlog-triage/SKILL.md +19 -6
  5. package/claude/skills/canvas/REQUIREMENT.md +49 -0
  6. package/claude/skills/canvas/SKILL.md +106 -0
  7. package/claude/skills/context-fold/REQUIREMENT.md +6 -2
  8. package/claude/skills/context-fold/SKILL.md +8 -6
  9. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  10. package/claude/skills/design-extract/REQUIREMENT.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/design-taste/SKILL.md +4 -2
  13. package/claude/skills/design-taste/references/adopted.md +40 -0
  14. package/claude/skills/design-taste/references/kinds.md +1 -1
  15. package/claude/skills/design-taste/references/systems.md +1 -7
  16. package/claude/skills/design-taste/references/tells.md +2 -24
  17. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  18. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  19. package/claude/skills/document-health/SKILL.md +21 -0
  20. package/claude/skills/document-health/references/axes.md +12 -0
  21. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  22. package/claude/skills/draft-doc/REQUIREMENT.md +1 -1
  23. package/claude/skills/draft-figure/REQUIREMENT.md +14 -3
  24. package/claude/skills/draft-figure/SKILL.md +24 -51
  25. package/claude/skills/draft-figure/references/architecture-set.md +35 -0
  26. package/claude/skills/draft-figure/references/hand-drawn.md +31 -0
  27. package/claude/skills/draft-figure/references/mermaid.md +55 -0
  28. package/claude/skills/draft-identity/SKILL.md +1 -1
  29. package/claude/skills/draft-screencast/SKILL.md +9 -7
  30. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  31. package/claude/skills/draft-slides/SKILL.md +31 -20
  32. package/claude/skills/git-followup/SKILL.md +17 -6
  33. package/claude/skills/git-issue/references/issue.md +10 -0
  34. package/claude/skills/git-pr/references/evidence.md +12 -2
  35. package/claude/skills/git-pr/references/labels.md +12 -0
  36. package/claude/skills/git-split/SKILL.md +19 -8
  37. package/claude/skills/index-lookup/SKILL.md +2 -2
  38. package/claude/skills/markdown-propose/references/format.md +9 -0
  39. package/claude/skills/memory-review/references/later-phases.md +7 -0
  40. package/claude/skills/review-address/SKILL.md +6 -13
  41. package/claude/skills/review-address/references/rest-reads.md +43 -0
  42. package/claude/skills/review-craft/references/ui.md +7 -1
  43. package/claude/skills/review-pr/REQUIREMENT.md +1 -0
  44. package/claude/skills/review-pr/SKILL.md +9 -3
  45. package/claude/skills/review-pr/references/close-out.md +2 -2
  46. package/claude/skills/review-pr/references/post.md +17 -5
  47. package/claude/skills/review-pr/references/scope.md +10 -8
  48. package/claude/skills/review-ui/SKILL.md +12 -4
  49. package/claude/skills/review-ui/references/post.md +4 -2
  50. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +38 -1
  51. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  52. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +4 -0
  53. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +4 -4
  54. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +6 -0
  55. package/claude/skills/role-orchestrator/scripts/poll.ts +90 -27
  56. package/claude/skills/role-orchestrator/scripts/repo.ts +3 -5
  57. package/claude/skills/role-orchestrator/scripts/watch.ts +4 -7
  58. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  59. package/claude/skills/search-craft/SKILL.md +3 -1
  60. package/claude/skills/search-craft/references/adopted.md +30 -0
  61. package/claude/skills/target-setup/references/detect.md +8 -0
  62. package/claude/skills/target-setup/references/indexes.md +13 -0
  63. package/claude/skills/target-setup/references/verify.md +8 -0
  64. package/claude/skills/test-first/SKILL.md +2 -0
  65. package/claude/skills/test-first/references/adopted.md +36 -0
  66. package/claude/skills/write-human/SKILL.md +1 -1
  67. package/claude/skills/write-human/references/machine-tells.md +13 -0
  68. package/docs/agents/canvas.md +4 -2
  69. package/docs/agents/capture.md +1 -1
  70. package/docs/agents/commands.md +47 -47
  71. package/docs/agents/context-audit-checks.md +2 -2
  72. package/docs/agents/index.md +2 -3
  73. package/docs/agents/key-changes.md +3 -3
  74. package/docs/agents/pr-evidence.md +11 -2
  75. package/docs/agents/pr-reads.md +5 -4
  76. package/docs/agents/records-stale.md +22 -9
  77. package/docs/agents/records.md +1 -1
  78. package/docs/agents/sandbox.md +1 -1
  79. package/docs/agents/skills-audit.md +10 -3
  80. package/docs/agents/slides.md +15 -3
  81. package/docs/target/projects.md +4 -4
  82. package/docs/workflow/ai-workflow.md +1 -2
  83. package/docs/workflow/index.md +1 -0
  84. package/docs/workflow/parallel-features.md +1 -1
  85. package/docs/workflow/skill-map-on-request.md +42 -0
  86. package/docs/workflow/skill-map.md +3 -36
  87. package/docs/workflow/visual-design-workflow.md +1 -0
  88. package/governance/renames.toml +0 -4
  89. package/governance/rules/standards/markdown.md +1 -0
  90. package/package.json +1 -2
  91. package/scripts/core/install-check.sh +1 -1
  92. package/scripts/lib/sandbox-dispatch.sh +1 -1
  93. package/scripts/lib/sandbox-git.sh +1 -9
  94. package/scripts/lib/sandbox-path.sh +1 -1
  95. package/scripts/sandbox-hook.sh +99 -0
  96. package/src/audits/catalog.ts +2 -2
  97. package/src/canvas/capture.ts +1 -9
  98. package/src/canvas/client/inspector/color-field.tsx +368 -0
  99. package/src/canvas/client/inspector/field.tsx +200 -0
  100. package/src/canvas/client/inspector/fields.css +292 -0
  101. package/src/canvas/client/inspector/section.tsx +27 -0
  102. package/src/canvas/client/inspector/values.ts +183 -0
  103. package/src/canvas/client/inspector.tsx +176 -160
  104. package/src/canvas/client/shell.css +0 -101
  105. package/src/capture/render.ts +45 -7
  106. package/src/capture/sources.ts +43 -0
  107. package/src/census/count.ts +1 -1
  108. package/src/claude/cases/misc.ts +5 -0
  109. package/src/claude/cases/workflow.ts +6 -1
  110. package/src/claude/seeds.ts +1 -2
  111. package/src/claude/skills-audit.ts +68 -14
  112. package/src/client-commands.ts +1 -1
  113. package/src/commands/claude.ts +27 -2
  114. package/src/commands/design.ts +2 -64
  115. package/src/commands/feedback.ts +3 -3
  116. package/src/commands/pr.ts +219 -202
  117. package/src/commands/records.ts +88 -6
  118. package/src/commands/sandbox.ts +50 -10
  119. package/src/commands/slides.ts +31 -77
  120. package/src/context/audit.ts +8 -7
  121. package/src/design/tokens.ts +2 -18
  122. package/src/docs/read.ts +1 -1
  123. package/src/{github.ts → feedback/github.ts} +1 -1
  124. package/src/gate/measures.ts +6 -4
  125. package/src/gov/citations.ts +1 -1
  126. package/src/gov/payload.ts +1 -1
  127. package/src/gov/superseded.ts +2 -2
  128. package/src/help.ts +2 -2
  129. package/src/markdown/ceiling.ts +1 -1
  130. package/src/migrate/skill-names.ts +7 -1
  131. package/src/pr/evidence.ts +25 -5
  132. package/src/pr/rest.ts +137 -0
  133. package/src/record-root.ts +1 -0
  134. package/src/records/backup.ts +1 -0
  135. package/src/records/push-guard.ts +1 -1
  136. package/src/records/stale-canonical.ts +146 -0
  137. package/src/records/stale.ts +1 -1
  138. package/src/sandbox/hooks.ts +128 -0
  139. package/src/sandbox/provision.ts +596 -0
  140. package/src/sandbox/tree.ts +103 -4
  141. package/src/secrets/marker.ts +2 -2
  142. package/src/secrets/scan.ts +1 -1
  143. package/src/shipped/references.ts +2 -2
  144. package/src/slides/convert/export.ts +2 -16
  145. package/src/slides/decks.ts +110 -0
  146. package/src/standards/read.ts +1 -1
  147. package/src/sync/seeds-report.ts +1 -1
  148. package/src/tasks/answers.ts +1 -1
  149. package/src/tasks/archive.ts +1 -1
  150. package/src/teach/nav.ts +1 -1
  151. package/src/tooling/inject.ts +1 -1
  152. package/src/web/readme-citations.ts +1 -1
  153. package/standards/architecture.md +1 -0
  154. package/standards/canvas.md +112 -0
  155. package/standards/context.md +2 -1
  156. package/standards/figures.md +0 -1
  157. package/standards/index.md +1 -1
  158. package/standards/markdown.md +1 -0
  159. package/standards/mermaid.md +1 -7
  160. package/standards/requirements.md +2 -0
  161. package/standards/skill-practice.md +2 -1
  162. package/standards/skill.md +1 -0
  163. package/tooling/claude/reference.md +0 -5
  164. package/claude/skills/draft-diagram/REQUIREMENT.md +0 -45
  165. package/claude/skills/draft-diagram/SKILL.md +0 -177
  166. package/docs/agents/design-board.md +0 -33
  167. package/governance/rules/standards/diagrams.md +0 -14
  168. package/governance/rules/standards/mermaid.md +0 -15
  169. package/scripts/core/check-gallery-exclusion.sh +0 -50
  170. package/scripts/manage-sandbox.sh +0 -472
  171. package/src/design/board.ts +0 -576
  172. package/src/slides/layouts.ts +0 -478
  173. package/src/slides/parse.ts +0 -84
  174. package/src/slides/render.ts +0 -110
  175. package/src/slides/styles.ts +0 -96
  176. package/standards/diagrams.md +0 -132
  177. package/tooling/claude/seeds/.claude/diagrams/index.md +0 -8
  178. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  179. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  180. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  181. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  182. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  183. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  184. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  185. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -0,0 +1,368 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { useLayoutEffect, useRef, useState } from 'preact/hooks'
4
+ import {
5
+ composeColor,
6
+ type ParsedColor,
7
+ readHex,
8
+ readOpacity,
9
+ } from '@/canvas/client/inspector/values'
10
+
11
+ interface ColorFieldProps {
12
+ /** The full word, which names the hex input for assistive technology. */
13
+ readonly label: string
14
+ /** What the element holds, or undefined for a form the picker cannot read. */
15
+ readonly color: ParsedColor | undefined
16
+ /** The value as written, shown when `color` is undefined. */
17
+ readonly text: string
18
+ /** The color the browser paints, which fills the swatch. */
19
+ readonly painted: string
20
+ /** The color tokens in the order the token sheet sorts them. */
21
+ readonly tokens: readonly string[]
22
+ /** The color a token paints in the frame, read when the picker opens. */
23
+ readonly paint: (name: string) => string
24
+ readonly isRaw: boolean
25
+ readonly isBusy: boolean
26
+ readonly onCommit: (value: string) => void
27
+ }
28
+
29
+ function shownHex(color: ParsedColor | undefined, text: string): string {
30
+ if (!color) return text
31
+ if (color.kind === 'none') return ''
32
+ return color.kind === 'token' ? color.name : color.hex
33
+ }
34
+
35
+ function shownOpacity(color: ParsedColor | undefined): string {
36
+ return color && color.kind !== 'none' ? String(color.opacity) : ''
37
+ }
38
+
39
+ function currentOpacity(color: ParsedColor | undefined): number {
40
+ return color && color.kind !== 'none' ? color.opacity : 100
41
+ }
42
+
43
+ /**
44
+ * What a typed hex writes. A token name typed whole picks the token, and any
45
+ * other text goes through as written, which the panel then marks raw.
46
+ */
47
+ function fromTyped(typed: string, opacity: number): string {
48
+ const hex = readHex(typed)
49
+ if (hex) {
50
+ return composeColor({
51
+ kind: 'hex',
52
+ hex: hex.hex,
53
+ opacity: hex.opacity ?? opacity,
54
+ })
55
+ }
56
+ if (/^--[\w-]+$/.test(typed)) {
57
+ return composeColor({ kind: 'token', name: typed, opacity })
58
+ }
59
+ return typed
60
+ }
61
+
62
+ interface ColorInputsProps {
63
+ readonly hexLabel: string
64
+ readonly opacityLabel: string
65
+ readonly color: ParsedColor | undefined
66
+ readonly text: string
67
+ readonly isBusy: boolean
68
+ readonly onCommit: (value: string) => void
69
+ }
70
+
71
+ /**
72
+ * The hex and the opacity. Each commits on change and posts nothing when the
73
+ * value is what it started at, or an opacity outside 0 to 100.
74
+ */
75
+ function ColorInputs({
76
+ hexLabel,
77
+ opacityLabel,
78
+ color,
79
+ text,
80
+ isBusy,
81
+ onCommit,
82
+ }: ColorInputsProps): JSX.Element {
83
+ const hex = shownHex(color, text)
84
+ const opacity = shownOpacity(color)
85
+ return (
86
+ <>
87
+ <input
88
+ class={
89
+ color === undefined
90
+ ? 'glyph-field-input is-text'
91
+ : color.kind === 'token'
92
+ ? 'glyph-field-input is-token'
93
+ : 'glyph-field-input'
94
+ }
95
+ type="text"
96
+ aria-label={hexLabel}
97
+ title={hex}
98
+ defaultValue={hex}
99
+ placeholder="None"
100
+ disabled={isBusy}
101
+ spellcheck={false}
102
+ onChange={(event) => {
103
+ const typed = event.currentTarget.value.trim()
104
+ if (typed === '' || typed === hex) return
105
+ onCommit(fromTyped(typed, currentOpacity(color)))
106
+ }}
107
+ onKeyDown={(event) => {
108
+ if (event.key === 'Escape') event.currentTarget.value = hex
109
+ }}
110
+ />
111
+ <input
112
+ class="color-opacity"
113
+ type="text"
114
+ inputMode="numeric"
115
+ aria-label={opacityLabel}
116
+ defaultValue={opacity}
117
+ disabled={isBusy || !color || color.kind === 'none'}
118
+ spellcheck={false}
119
+ onChange={(event) => {
120
+ const percent = readOpacity(event.currentTarget.value)
121
+ if (
122
+ percent === undefined ||
123
+ !color ||
124
+ color.kind === 'none' ||
125
+ percent === color.opacity
126
+ ) {
127
+ event.currentTarget.value = opacity
128
+ return
129
+ }
130
+ onCommit(composeColor({ ...color, opacity: percent }))
131
+ }}
132
+ onKeyDown={(event) => {
133
+ if (event.key === 'Escape') event.currentTarget.value = opacity
134
+ }}
135
+ />
136
+ {opacity ? (
137
+ <span class="color-percent" aria-hidden="true">
138
+ %
139
+ </span>
140
+ ) : null}
141
+ </>
142
+ )
143
+ }
144
+
145
+ /**
146
+ * The swatch whose picker last wrote a value, so it takes focus back each time
147
+ * the frame reloads. A saved edit remounts the inspector, once for the edit and
148
+ * again when the file watcher reports the write, and each remount replaces the
149
+ * swatch that held focus and drops it to the page. It holds until focus lands
150
+ * anywhere else.
151
+ */
152
+ let refocus: string | undefined
153
+
154
+ function swatchLabel(label: string): string {
155
+ return `${label} picker`
156
+ }
157
+
158
+ if (typeof document !== 'undefined') {
159
+ document.addEventListener('focusin', (event) => {
160
+ if (refocus === undefined) return
161
+ const target = event.target
162
+ const name =
163
+ target instanceof Element ? target.getAttribute('aria-label') : null
164
+ if (name !== swatchLabel(refocus)) refocus = undefined
165
+ })
166
+ }
167
+
168
+ /**
169
+ * One color property: a swatch opening the picker, the hex, and the opacity.
170
+ * The picker lists the project's color tokens ahead of any raw value, since a
171
+ * token keeps following the theme and a literal does not.
172
+ */
173
+ export function ColorField({
174
+ label,
175
+ color,
176
+ text,
177
+ painted,
178
+ tokens,
179
+ paint,
180
+ isRaw,
181
+ isBusy,
182
+ onCommit,
183
+ }: ColorFieldProps): JSX.Element {
184
+ const [isOpen, setOpen] = useState(false)
185
+ const swatch = useRef<HTMLButtonElement>(null)
186
+ const list = useRef<HTMLUListElement>(null)
187
+ const dialog = useRef<HTMLDivElement>(null)
188
+ const picked = color?.kind === 'token' ? tokens.indexOf(color.name) : -1
189
+ const [active, setActive] = useState(Math.max(0, picked))
190
+ const isNone = color?.kind === 'none'
191
+
192
+ /*
193
+ * Focus moves in the commit rather than after paint, so a key typed straight
194
+ * after the one that opened the picker reaches the list, not the swatch.
195
+ */
196
+ useLayoutEffect(() => {
197
+ if (!isOpen) return
198
+ const target =
199
+ list.current ??
200
+ dialog.current?.querySelector<HTMLInputElement>('input') ??
201
+ null
202
+ target?.focus()
203
+ }, [isOpen])
204
+
205
+ useLayoutEffect(() => {
206
+ if (refocus === label) swatch.current?.focus()
207
+ }, [label])
208
+
209
+ const close = () => {
210
+ setOpen(false)
211
+ swatch.current?.focus()
212
+ }
213
+
214
+ /* Writes from inside the picker, whose swatch keeps focus across the reload. */
215
+ const commitFromPicker = (value: string) => {
216
+ close()
217
+ refocus = label
218
+ onCommit(value)
219
+ }
220
+
221
+ const pick = (name: string) => {
222
+ if (color?.kind === 'token' && color.name === name) {
223
+ close()
224
+ return
225
+ }
226
+ commitFromPicker(
227
+ composeColor({ kind: 'token', name, opacity: currentOpacity(color) }),
228
+ )
229
+ }
230
+
231
+ const id = `color-${label.replace(/\W+/g, '-')}`
232
+
233
+ return (
234
+ <div
235
+ class="color-field is-wide"
236
+ onFocusOut={(event) => {
237
+ const next = event.relatedTarget
238
+ if (!(next instanceof Node) || !event.currentTarget.contains(next)) {
239
+ setOpen(false)
240
+ }
241
+ }}
242
+ >
243
+ <div class="fill-row">
244
+ <div class="glyph-field">
245
+ <button
246
+ ref={swatch}
247
+ class={isNone ? 'swatch is-none' : 'swatch'}
248
+ type="button"
249
+ aria-label={swatchLabel(label)}
250
+ aria-haspopup="dialog"
251
+ aria-expanded={isOpen}
252
+ title={label}
253
+ disabled={isBusy}
254
+ onClick={() => {
255
+ setActive(Math.max(0, picked))
256
+ setOpen(!isOpen)
257
+ }}
258
+ >
259
+ <span
260
+ class="swatch-paint"
261
+ style={isNone ? undefined : { '--paint': painted }}
262
+ />
263
+ </button>
264
+ <ColorInputs
265
+ hexLabel={label}
266
+ opacityLabel={`${label} opacity`}
267
+ color={color}
268
+ text={text}
269
+ isBusy={isBusy}
270
+ onCommit={onCommit}
271
+ />
272
+ </div>
273
+ {isRaw ? (
274
+ <span
275
+ class="raw"
276
+ title="Set as a raw value, so it will not follow the theme. Pick a token to fix it"
277
+ >
278
+ raw
279
+ </span>
280
+ ) : null}
281
+ </div>
282
+ {isOpen ? (
283
+ <div
284
+ ref={dialog}
285
+ class="color-picker"
286
+ role="dialog"
287
+ aria-label={`${label} colors`}
288
+ tabIndex={-1}
289
+ onKeyDown={(event) => {
290
+ if (event.key !== 'Escape') return
291
+ event.preventDefault()
292
+ /* A blur would commit what was typed, so put it back first. */
293
+ if (event.target instanceof HTMLInputElement) {
294
+ event.target.value = event.target.defaultValue
295
+ }
296
+ close()
297
+ }}
298
+ >
299
+ {tokens.length > 0 ? (
300
+ <ul
301
+ ref={list}
302
+ class="token-list"
303
+ role="listbox"
304
+ aria-label="Tokens"
305
+ tabIndex={0}
306
+ aria-activedescendant={`${id}-${active}`}
307
+ onKeyDown={(event) => {
308
+ const last = tokens.length - 1
309
+ const moves: Record<string, number> = {
310
+ ArrowDown: Math.min(last, active + 1),
311
+ ArrowUp: Math.max(0, active - 1),
312
+ Home: 0,
313
+ End: last,
314
+ }
315
+ if (event.key in moves) {
316
+ event.preventDefault()
317
+ setActive(moves[event.key] ?? active)
318
+ } else if (event.key === 'Enter' || event.key === ' ') {
319
+ event.preventDefault()
320
+ const name = tokens[active]
321
+ if (name) pick(name)
322
+ }
323
+ }}
324
+ >
325
+ {tokens.map((name, index) => (
326
+ <li
327
+ key={name}
328
+ id={`${id}-${index}`}
329
+ class={
330
+ index === active ? 'token-option is-active' : 'token-option'
331
+ }
332
+ role="option"
333
+ aria-selected={index === picked}
334
+ title={name}
335
+ onClick={() => pick(name)}
336
+ >
337
+ <span
338
+ class="swatch-paint"
339
+ aria-hidden="true"
340
+ style={{ '--paint': paint(name) }}
341
+ />
342
+ <span class="token-name">{name}</span>
343
+ </li>
344
+ ))}
345
+ </ul>
346
+ ) : (
347
+ <p class="empty">
348
+ No color tokens resolve. Add them to the token sheet
349
+ </p>
350
+ )}
351
+ <div class="glyph-field">
352
+ <span class="glyph" aria-hidden="true">
353
+ #
354
+ </span>
355
+ <ColorInputs
356
+ hexLabel="hex"
357
+ opacityLabel="opacity"
358
+ color={color}
359
+ text={text}
360
+ isBusy={isBusy}
361
+ onCommit={commitFromPicker}
362
+ />
363
+ </div>
364
+ </div>
365
+ ) : null}
366
+ </div>
367
+ )
368
+ }
@@ -0,0 +1,200 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { useRef } from 'preact/hooks'
4
+ import { scrubStep } from '@/canvas/client/inspector/values'
5
+
6
+ /**
7
+ * One drag of a number field's glyph. Each call takes the value as the field
8
+ * shows it. Only `commit` writes, so a drag posts one edit at most.
9
+ */
10
+ export interface ScrubSession {
11
+ readonly preview: (value: string) => void
12
+ readonly restore: () => void
13
+ readonly commit: (value: string) => void
14
+ }
15
+
16
+ /**
17
+ * Opens a session at press, so what `restore` returns to is read before any
18
+ * preview, however often the panel renders during the drag.
19
+ */
20
+ export interface Scrub {
21
+ readonly begin: () => ScrubSession
22
+ /** Holds a scrubbed number inside what the property accepts. */
23
+ readonly clamp: (value: number) => number
24
+ }
25
+
26
+ interface FieldProps {
27
+ /** The full word, which names the input for assistive technology. */
28
+ readonly label: string
29
+ /** The short mark drawn inside the field, hidden from assistive technology. */
30
+ readonly glyph: string
31
+ readonly initial: string
32
+ readonly isBusy: boolean
33
+ readonly isWide?: boolean
34
+ readonly placeholder?: string
35
+ readonly onCommit: (value: string) => void
36
+ /** Makes the glyph a drag handle. Typing stays the keyboard path. */
37
+ readonly scrub?: Scrub
38
+ }
39
+
40
+ const NUMBER = /^-?\d*\.?\d+$/
41
+
42
+ interface Drag {
43
+ readonly pointerId: number
44
+ /** What the input held at press, which a cancel puts back. */
45
+ readonly typed: string
46
+ /** The number the drag started from, an empty field's placeholder included. */
47
+ readonly start: string
48
+ readonly session: ScrubSession
49
+ lastX: number
50
+ value: number
51
+ }
52
+
53
+ function fieldClass(isWide: boolean | undefined, modifier?: string): string {
54
+ return ['glyph-field', isWide ? 'is-wide' : '', modifier ?? '']
55
+ .filter(Boolean)
56
+ .join(' ')
57
+ }
58
+
59
+ /**
60
+ * Commits on change, which a text input fires on Enter or on leaving it, and
61
+ * sends nothing when the value is what it started at. Escape puts it back.
62
+ */
63
+ export function Field({
64
+ label,
65
+ glyph,
66
+ initial,
67
+ isBusy,
68
+ isWide,
69
+ placeholder,
70
+ onCommit,
71
+ scrub,
72
+ }: FieldProps): JSX.Element {
73
+ const input = useRef<HTMLInputElement>(null)
74
+ const handle = useRef<HTMLSpanElement>(null)
75
+ const drag = useRef<Drag | null>(null)
76
+
77
+ const end = (pointerId: number): Drag | undefined => {
78
+ const held = drag.current
79
+ if (!held || held.pointerId !== pointerId) return undefined
80
+ drag.current = null
81
+ return held
82
+ }
83
+
84
+ /* An empty field scrubs from the number its placeholder shows. */
85
+ const scrubFrom = (value: string): string =>
86
+ value.trim() || placeholder?.trim() || ''
87
+ const isScrubbable = scrub !== undefined && NUMBER.test(scrubFrom(initial))
88
+
89
+ const handlers = isScrubbable
90
+ ? {
91
+ onPointerDown: (event: PointerEvent) => {
92
+ const typed = input.current?.value ?? ''
93
+ const start = scrubFrom(typed)
94
+ if (isBusy || !NUMBER.test(start)) return
95
+ event.preventDefault()
96
+ /*
97
+ * Capture keeps the release coming here when the pointer leaves the
98
+ * glyph, the panel, or crosses into a frame's own document.
99
+ */
100
+ try {
101
+ handle.current?.setPointerCapture(event.pointerId)
102
+ } catch {
103
+ /* A pointer the browser no longer tracks ends on cancel instead. */
104
+ }
105
+ drag.current = {
106
+ pointerId: event.pointerId,
107
+ typed,
108
+ start,
109
+ session: scrub.begin(),
110
+ lastX: event.clientX,
111
+ value: Number(start),
112
+ }
113
+ },
114
+ onPointerMove: (event: PointerEvent) => {
115
+ const held = drag.current
116
+ if (!held || held.pointerId !== event.pointerId) return
117
+ const moved = event.clientX - held.lastX
118
+ held.lastX = event.clientX
119
+ held.value = scrub.clamp(
120
+ Math.round((held.value + moved * scrubStep(event.shiftKey)) * 10) /
121
+ 10,
122
+ )
123
+ const shown = String(held.value)
124
+ if (input.current) input.current.value = shown
125
+ held.session.preview(shown)
126
+ },
127
+ onPointerUp: (event: PointerEvent) => {
128
+ const held = end(event.pointerId)
129
+ if (!held) return
130
+ const shown = String(held.value)
131
+ if (shown === held.start) held.session.restore()
132
+ else held.session.commit(shown)
133
+ },
134
+ onPointerCancel: (event: PointerEvent) => {
135
+ const held = end(event.pointerId)
136
+ if (!held) return
137
+ if (input.current) input.current.value = held.typed
138
+ held.session.restore()
139
+ },
140
+ }
141
+ : {}
142
+
143
+ return (
144
+ <div class={fieldClass(isWide)}>
145
+ <span
146
+ ref={handle}
147
+ class={isScrubbable ? 'glyph is-scrub' : 'glyph'}
148
+ aria-hidden="true"
149
+ title={label}
150
+ {...handlers}
151
+ >
152
+ {glyph}
153
+ </span>
154
+ <input
155
+ ref={input}
156
+ class="glyph-field-input"
157
+ type="text"
158
+ aria-label={label}
159
+ title={initial}
160
+ defaultValue={initial}
161
+ placeholder={placeholder}
162
+ disabled={isBusy}
163
+ spellcheck={false}
164
+ onChange={(event) => {
165
+ const value = event.currentTarget.value.trim()
166
+ if (value !== initial.trim()) onCommit(value)
167
+ }}
168
+ onKeyDown={(event) => {
169
+ if (event.key === 'Escape') event.currentTarget.value = initial
170
+ }}
171
+ />
172
+ </div>
173
+ )
174
+ }
175
+
176
+ interface ReadOnlyFieldProps {
177
+ readonly label: string
178
+ readonly glyph: string
179
+ readonly value: string
180
+ readonly isWide?: boolean
181
+ }
182
+
183
+ /** A value the panel shows and nothing here writes, drawn like a field. */
184
+ export function ReadOnlyField({
185
+ label,
186
+ glyph,
187
+ value,
188
+ isWide,
189
+ }: ReadOnlyFieldProps): JSX.Element {
190
+ return (
191
+ <div class={fieldClass(isWide, 'is-readonly')}>
192
+ <span class="glyph" aria-hidden="true" title={label}>
193
+ {glyph}
194
+ </span>
195
+ <output class="glyph-field-input" aria-label={label} title={value}>
196
+ {value}
197
+ </output>
198
+ </div>
199
+ )
200
+ }