@erclx/canon 5.26.0 → 5.27.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 (242) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +2 -0
  4. package/claude/skills/auto-ship/SKILL.md +3 -11
  5. package/claude/skills/auto-ship/references/review-findings.md +20 -0
  6. package/claude/skills/backlog-triage/SKILL.md +2 -0
  7. package/claude/skills/bash-cli-script/SKILL.md +2 -0
  8. package/claude/skills/build-in-slices/SKILL.md +2 -0
  9. package/claude/skills/canon-cli/SKILL.md +2 -0
  10. package/claude/skills/canon-feedback/SKILL.md +2 -0
  11. package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
  12. package/claude/skills/canon-operator/SKILL.md +2 -0
  13. package/claude/skills/canon-rollout/SKILL.md +3 -11
  14. package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
  15. package/claude/skills/canvas/SKILL.md +13 -0
  16. package/claude/skills/ci-workflow/SKILL.md +2 -0
  17. package/claude/skills/code-craft/SKILL.md +2 -0
  18. package/claude/skills/codebase-layout/SKILL.md +2 -0
  19. package/claude/skills/context-fold/SKILL.md +3 -17
  20. package/claude/skills/context-fold/references/completion-report.md +26 -0
  21. package/claude/skills/create-rule/SKILL.md +2 -0
  22. package/claude/skills/create-skill/SKILL.md +2 -0
  23. package/claude/skills/create-standard/SKILL.md +2 -0
  24. package/claude/skills/decision-escalate/SKILL.md +2 -0
  25. package/claude/skills/deploy-app/SKILL.md +2 -0
  26. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  27. package/claude/skills/design-extract/SKILL.md +2 -0
  28. package/claude/skills/design-taste/SKILL.md +2 -0
  29. package/claude/skills/docs-sync/SKILL.md +2 -0
  30. package/claude/skills/document-health/SKILL.md +2 -0
  31. package/claude/skills/draft-and-pick/SKILL.md +2 -0
  32. package/claude/skills/draft-doc/SKILL.md +2 -0
  33. package/claude/skills/draft-doc/references/wiki.md +1 -1
  34. package/claude/skills/draft-figure/SKILL.md +2 -0
  35. package/claude/skills/draft-identity/SKILL.md +2 -0
  36. package/claude/skills/draft-ready/SKILL.md +2 -0
  37. package/claude/skills/draft-screencast/SKILL.md +2 -0
  38. package/claude/skills/draft-slides/SKILL.md +2 -0
  39. package/claude/skills/git-branch/SKILL.md +2 -0
  40. package/claude/skills/git-commit/SKILL.md +2 -0
  41. package/claude/skills/git-followup/SKILL.md +2 -0
  42. package/claude/skills/git-issue/SKILL.md +2 -0
  43. package/claude/skills/git-pr/SKILL.md +2 -0
  44. package/claude/skills/git-pr/references/evidence.md +6 -6
  45. package/claude/skills/git-ship/SKILL.md +2 -0
  46. package/claude/skills/git-split/SKILL.md +2 -0
  47. package/claude/skills/git-stage/SKILL.md +2 -0
  48. package/claude/skills/git-worktree/SKILL.md +2 -0
  49. package/claude/skills/index-lookup/SKILL.md +2 -0
  50. package/claude/skills/markdown-propose/SKILL.md +2 -0
  51. package/claude/skills/memory-capture/SKILL.md +2 -0
  52. package/claude/skills/memory-review/SKILL.md +2 -0
  53. package/claude/skills/plan-feature/SKILL.md +2 -0
  54. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  55. package/claude/skills/plan-intake/SKILL.md +2 -0
  56. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  57. package/claude/skills/project-commands/SKILL.md +2 -0
  58. package/claude/skills/read-frames/SKILL.md +2 -0
  59. package/claude/skills/record-screencast/SKILL.md +2 -0
  60. package/claude/skills/repo-metadata/SKILL.md +2 -0
  61. package/claude/skills/restate-plainly/SKILL.md +2 -0
  62. package/claude/skills/review-address/SKILL.md +3 -7
  63. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  64. package/claude/skills/review-branch/SKILL.md +2 -0
  65. package/claude/skills/review-craft/SKILL.md +2 -0
  66. package/claude/skills/review-craft/references/ui.md +2 -2
  67. package/claude/skills/review-pr/SKILL.md +4 -8
  68. package/claude/skills/review-pr/references/body-variants.md +52 -0
  69. package/claude/skills/review-pr/references/key-changes.md +16 -0
  70. package/claude/skills/review-pr/references/post.md +15 -43
  71. package/claude/skills/review-ui/SKILL.md +3 -1
  72. package/claude/skills/role-orchestrator/SKILL.md +5 -14
  73. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  74. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  75. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
  76. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
  77. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  78. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  79. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  80. package/claude/skills/role-planner/SKILL.md +2 -0
  81. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  82. package/claude/skills/role-reviewer/SKILL.md +2 -0
  83. package/claude/skills/role-worker/SKILL.md +2 -0
  84. package/claude/skills/search-craft/SKILL.md +2 -0
  85. package/claude/skills/seed-sync/SKILL.md +2 -0
  86. package/claude/skills/session-compact/SKILL.md +2 -0
  87. package/claude/skills/session-map/SKILL.md +2 -0
  88. package/claude/skills/session-relay/SKILL.md +2 -0
  89. package/claude/skills/session-resume/SKILL.md +2 -0
  90. package/claude/skills/session-worktree/SKILL.md +3 -11
  91. package/claude/skills/session-worktree/references/install-command.md +20 -0
  92. package/claude/skills/sketch-design/SKILL.md +2 -0
  93. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  94. package/claude/skills/target-check/SKILL.md +2 -0
  95. package/claude/skills/target-setup/SKILL.md +2 -0
  96. package/claude/skills/task-board/SKILL.md +2 -0
  97. package/claude/skills/teach-workspace/SKILL.md +5 -15
  98. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  99. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  100. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  101. package/claude/skills/test-craft/SKILL.md +2 -0
  102. package/claude/skills/test-first/SKILL.md +2 -0
  103. package/claude/skills/ui-checklist/SKILL.md +2 -0
  104. package/claude/skills/ux-audit/SKILL.md +2 -0
  105. package/claude/skills/ux-measure/SKILL.md +2 -0
  106. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  107. package/claude/skills/video-craft/SKILL.md +2 -0
  108. package/claude/skills/write-human/SKILL.md +2 -0
  109. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  110. package/docs/agents/canvas.md +23 -7
  111. package/docs/agents/capture.md +5 -5
  112. package/docs/agents/commands.md +2 -2
  113. package/docs/agents/index.md +3 -3
  114. package/docs/agents/install-and-sync.md +7 -0
  115. package/docs/agents/key-changes.md +1 -1
  116. package/docs/agents/pr-evidence.md +38 -15
  117. package/docs/agents/pr-local.md +19 -13
  118. package/docs/agents/pr-preview.md +44 -21
  119. package/docs/agents/sandbox.md +28 -4
  120. package/docs/agents/scripting.md +8 -1
  121. package/docs/agents/skills-audit.md +2 -1
  122. package/docs/workflow/operating-model.md +5 -6
  123. package/docs/workflow/parallel-features.md +22 -1
  124. package/docs/workflow/skill-map.md +3 -1
  125. package/docs/workflow/zshrc-aliases.md +1 -1
  126. package/package.json +1 -1
  127. package/scripts/core/regen-hero.ts +1 -1
  128. package/scripts/lib/sandbox-path.sh +6 -162
  129. package/src/audits/run.ts +1 -1
  130. package/src/canvas/address.ts +12 -3
  131. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  132. package/src/canvas/client/inspector/color-field.tsx +201 -171
  133. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  134. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  135. package/src/canvas/client/inspector/color-space.ts +240 -0
  136. package/src/canvas/client/inspector/field.tsx +5 -1
  137. package/src/canvas/client/inspector/fields.css +395 -6
  138. package/src/canvas/client/inspector/size-field.tsx +69 -0
  139. package/src/canvas/client/inspector/values.ts +43 -1
  140. package/src/canvas/client/inspector.tsx +190 -4
  141. package/src/canvas/client/selection.tsx +197 -0
  142. package/src/canvas/client/shell.css +104 -12
  143. package/src/canvas/client/state.ts +69 -9
  144. package/src/canvas/client/surface.tsx +147 -22
  145. package/src/canvas/content.ts +50 -1
  146. package/src/canvas/edit.ts +37 -4
  147. package/src/canvas/editing.ts +203 -0
  148. package/src/canvas/global.d.ts +15 -0
  149. package/src/canvas/server.ts +149 -12
  150. package/src/canvas/tokens.ts +59 -17
  151. package/src/census/count.ts +1 -1
  152. package/src/claude/seeds.ts +1 -1
  153. package/src/claude/skills-audit.ts +28 -0
  154. package/src/claude/skills-drift.ts +1 -1
  155. package/src/claude/skills-families.ts +36 -0
  156. package/src/claude/skills-list.ts +76 -15
  157. package/src/commands/audits.ts +2 -2
  158. package/src/commands/canvas.ts +179 -2
  159. package/src/commands/claude.ts +67 -9
  160. package/src/commands/design.ts +142 -2
  161. package/src/commands/gov.ts +1 -1
  162. package/src/commands/hooks.ts +1 -1
  163. package/src/commands/indexes.ts +1 -1
  164. package/src/commands/init.ts +1 -1
  165. package/src/commands/intake.ts +1 -1
  166. package/src/commands/migrate.ts +1 -1
  167. package/src/commands/pr.ts +105 -58
  168. package/src/commands/records.ts +1 -1
  169. package/src/commands/repo.ts +1 -1
  170. package/src/commands/sandbox.ts +54 -2
  171. package/src/commands/sessions.ts +1 -1
  172. package/src/commands/sync.ts +1 -1
  173. package/src/commands/tasks.ts +2 -2
  174. package/src/commands/teach.ts +1 -1
  175. package/src/commands/transcripts.ts +1 -1
  176. package/src/commands/worktrees.ts +1 -1
  177. package/src/comments/scan.ts +1 -1
  178. package/src/comments/trend.ts +1 -1
  179. package/src/context/citations.ts +1 -1
  180. package/src/context/classify/extract.ts +2 -2
  181. package/src/design/project-fonts.ts +392 -0
  182. package/src/feedback/github.ts +1 -1
  183. package/src/gate/measures.ts +47 -9
  184. package/src/gate/sequencer.ts +1 -1
  185. package/src/gate/stages.ts +9 -0
  186. package/src/{git-files.ts → git/files.ts} +2 -2
  187. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  188. package/src/{worktree.ts → git/worktree.ts} +1 -1
  189. package/src/gov/citations.ts +1 -1
  190. package/src/gov/install.ts +1 -1
  191. package/src/gov/superseded.ts +1 -1
  192. package/src/gov/test-order.ts +2 -2
  193. package/src/help.ts +1 -1
  194. package/src/hooks/post-merge.ts +1 -1
  195. package/src/indexes/walk.ts +1 -1
  196. package/src/labels/audit.ts +1 -1
  197. package/src/markdown/files.ts +1 -1
  198. package/src/migrate/apply.ts +1 -1
  199. package/src/pr/bijection.ts +1 -1
  200. package/src/pr/evidence.ts +96 -43
  201. package/src/pr/local.ts +16 -10
  202. package/src/pr/preview.ts +78 -2
  203. package/src/records/backup.ts +1 -1
  204. package/src/records/stale-canonical.ts +1 -1
  205. package/src/sandbox/headless/dispatch.ts +264 -0
  206. package/src/sandbox/headless/run.ts +653 -0
  207. package/src/sandbox/headless/watch.ts +145 -0
  208. package/src/sandbox/provision.ts +12 -1
  209. package/src/sandbox/tree.ts +4 -8
  210. package/src/secrets/scan.ts +1 -1
  211. package/src/sessions/claim.ts +1 -1
  212. package/src/sessions/resolve.ts +1 -1
  213. package/src/sessions/transfer/bundle.ts +1 -1
  214. package/src/slides/package/fonts.ts +6 -2
  215. package/src/standards/audit.ts +2 -2
  216. package/src/sync/check.ts +2 -2
  217. package/src/sync/engine.ts +2 -2
  218. package/src/sync/git.ts +1 -1
  219. package/src/sync/history.ts +1 -1
  220. package/src/sync/reverse.ts +1 -1
  221. package/src/sync/target.ts +1 -1
  222. package/src/sync/workflow.ts +1 -1
  223. package/src/targets/pulls.ts +2 -2
  224. package/src/targets/sweep.ts +2 -2
  225. package/src/tasks/reach.ts +1 -1
  226. package/src/tasks/trunk.ts +1 -1
  227. package/src/tooling/inject.ts +1 -1
  228. package/src/tooling/subfolder.ts +1 -1
  229. package/src/web/readme-citations.ts +1 -1
  230. package/src/worktrees/bare-flag.ts +1 -1
  231. package/src/worktrees/reclaim.ts +2 -2
  232. package/src/worktrees/remove.ts +1 -1
  233. package/standards/canvas.md +2 -1
  234. package/standards/skill.md +1 -0
  235. package/standards/wiki.md +12 -3
  236. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  237. package/scripts/lib/sandbox-dispatch.sh +0 -190
  238. /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
  239. /package/src/{git-base.ts → git/base.ts} +0 -0
  240. /package/src/{git-env.ts → git/env.ts} +0 -0
  241. /package/src/{copy.ts → sync/copy.ts} +0 -0
  242. /package/src/{target.ts → targets/validate.ts} +0 -0
@@ -0,0 +1,162 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { useRef, useState } from 'preact/hooks'
4
+
5
+ /** The value each third of an axis writes, start to end as the axis runs. */
6
+ const POSITIONS = ['flex-start', 'center', 'flex-end'] as const
7
+
8
+ const ROWS = ['top', 'middle', 'bottom'] as const
9
+ const COLUMNS = ['left', 'center', 'right'] as const
10
+
11
+ /**
12
+ * Computed values that read as one of the three positions. A container left
13
+ * at its defaults packs from the start, and stretched children do too, so
14
+ * both show the start cell without stating it.
15
+ */
16
+ const READS: Readonly<Record<string, number>> = {
17
+ normal: 0,
18
+ stretch: 0,
19
+ 'flex-start': 0,
20
+ start: 0,
21
+ 'self-start': 0,
22
+ left: 0,
23
+ center: 1,
24
+ 'flex-end': 2,
25
+ end: 2,
26
+ 'self-end': 2,
27
+ right: 2,
28
+ }
29
+
30
+ /** Values a container takes without stating a position, which a pick pins. */
31
+ const IMPLIED = new Set(['normal', 'stretch'])
32
+
33
+ export interface Alignment {
34
+ readonly justifyContent: string
35
+ readonly alignItems: string
36
+ }
37
+
38
+ interface AlignGridProps {
39
+ /** The container's computed `flex-direction`, which decides the axes. */
40
+ readonly direction: string
41
+ readonly justifyContent: string
42
+ readonly alignItems: string
43
+ readonly isBusy: boolean
44
+ readonly onPick: (alignment: Alignment) => void
45
+ }
46
+
47
+ function isRow(direction: string): boolean {
48
+ return !direction.startsWith('column')
49
+ }
50
+
51
+ /**
52
+ * The cell, as row then column on screen, that a container's alignment puts
53
+ * its children in. A reversed main axis runs from the far edge, so its start
54
+ * is the right or the bottom.
55
+ */
56
+ function cellOf(
57
+ direction: string,
58
+ justifyContent: string,
59
+ alignItems: string,
60
+ ): readonly [number, number] | undefined {
61
+ const main = READS[justifyContent.trim()]
62
+ const cross = READS[alignItems.trim()]
63
+ if (main === undefined || cross === undefined) return undefined
64
+ const along = direction.endsWith('reverse') ? 2 - main : main
65
+ return isRow(direction) ? [cross, along] : [along, cross]
66
+ }
67
+
68
+ function alignmentOf(
69
+ direction: string,
70
+ row: number,
71
+ column: number,
72
+ ): Alignment {
73
+ const [main, cross] = isRow(direction) ? [column, row] : [row, column]
74
+ const along = direction.endsWith('reverse') ? 2 - main : main
75
+ return {
76
+ justifyContent: POSITIONS[along],
77
+ alignItems: POSITIONS[cross],
78
+ }
79
+ }
80
+
81
+ const KEY_STEPS: Readonly<Record<string, readonly [number, number]>> = {
82
+ ArrowUp: [-1, 0],
83
+ ArrowDown: [1, 0],
84
+ ArrowLeft: [0, -1],
85
+ ArrowRight: [0, 1],
86
+ }
87
+
88
+ /**
89
+ * Nine cells placing a flex container's children, one radio group. The
90
+ * arrow keys move focus inside the grid and stop at its edges, and a press
91
+ * or Enter or Space picks, since each pick writes the frame twice.
92
+ */
93
+ export function AlignGrid({
94
+ direction,
95
+ justifyContent,
96
+ alignItems,
97
+ isBusy,
98
+ onPick,
99
+ }: AlignGridProps): JSX.Element {
100
+ const checked = cellOf(direction, justifyContent, alignItems)
101
+ const isImplied =
102
+ IMPLIED.has(justifyContent.trim()) || IMPLIED.has(alignItems.trim())
103
+ const [focus, setFocus] = useState<readonly [number, number]>(
104
+ checked ?? [0, 0],
105
+ )
106
+ const cells = useRef<(HTMLButtonElement | null)[]>([])
107
+
108
+ const handleKeyDown = (event: KeyboardEvent) => {
109
+ const step = KEY_STEPS[event.key]
110
+ if (!step) return
111
+ event.preventDefault()
112
+ const next = [
113
+ Math.min(2, Math.max(0, focus[0] + step[0])),
114
+ Math.min(2, Math.max(0, focus[1] + step[1])),
115
+ ] as const
116
+ setFocus(next)
117
+ cells.current[next[0] * 3 + next[1]]?.focus()
118
+ }
119
+
120
+ return (
121
+ <div
122
+ class="align-grid"
123
+ role="radiogroup"
124
+ aria-label="Alignment"
125
+ onKeyDown={handleKeyDown}
126
+ >
127
+ {ROWS.map((rowName, row) =>
128
+ COLUMNS.map((columnName, column) => {
129
+ const isChecked = checked?.[0] === row && checked[1] === column
130
+ const isFocusable = focus[0] === row && focus[1] === column
131
+ const name =
132
+ rowName === 'middle' && columnName === 'center'
133
+ ? 'center'
134
+ : `${rowName} ${columnName}`
135
+ return (
136
+ <button
137
+ key={name}
138
+ ref={(node) => {
139
+ cells.current[row * 3 + column] = node
140
+ }}
141
+ type="button"
142
+ class="align-cell"
143
+ role="radio"
144
+ aria-label={name}
145
+ aria-checked={isChecked}
146
+ tabIndex={isFocusable ? 0 : -1}
147
+ disabled={isBusy}
148
+ onFocus={() => setFocus([row, column])}
149
+ onClick={() => {
150
+ if (!isChecked || isImplied) {
151
+ onPick(alignmentOf(direction, row, column))
152
+ }
153
+ }}
154
+ >
155
+ <span class="align-dot" aria-hidden="true" />
156
+ </button>
157
+ )
158
+ }),
159
+ )}
160
+ </div>
161
+ )
162
+ }
@@ -1,11 +1,20 @@
1
1
  /** @jsxImportSource preact */
2
2
  import type { JSX } from 'preact'
3
3
  import { useLayoutEffect, useRef, useState } from 'preact/hooks'
4
+ import type { ScrubSession } from '@/canvas/client/inspector/field'
5
+ import {
6
+ ColorInputs,
7
+ currentOpacity,
8
+ } from '@/canvas/client/inspector/color-inputs'
9
+ import {
10
+ ColorPicker,
11
+ isPickerHeld,
12
+ releasePicker,
13
+ } from '@/canvas/client/inspector/color-picker'
4
14
  import {
5
15
  composeColor,
6
16
  type ParsedColor,
7
17
  readHex,
8
- readOpacity,
9
18
  } from '@/canvas/client/inspector/values'
10
19
 
11
20
  interface ColorFieldProps {
@@ -15,144 +24,42 @@ interface ColorFieldProps {
15
24
  readonly color: ParsedColor | undefined
16
25
  /** The value as written, shown when `color` is undefined. */
17
26
  readonly text: string
27
+ /** The element's own inline value, empty where none stands. */
28
+ readonly inline: string
18
29
  /** The color the browser paints, which fills the swatch. */
19
30
  readonly painted: string
20
31
  /** The color tokens in the order the token sheet sorts them. */
21
32
  readonly tokens: readonly string[]
22
- /** The color a token paints in the frame, read when the picker opens. */
33
+ /** The color a token paints in the frame, read when the list opens. */
23
34
  readonly paint: (name: string) => string
35
+ /** The frame, element, and property, so a reload reopens the right picker. */
36
+ readonly owner: string
24
37
  readonly isRaw: boolean
25
38
  readonly isBusy: boolean
39
+ /** Opens a session previewing into the frame, where the element takes one. */
40
+ readonly preview?: () => ScrubSession
26
41
  readonly onCommit: (value: string) => void
27
42
  }
28
43
 
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
  /**
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.
45
+ * The accessible name of the row control that last wrote a value, so it takes
46
+ * focus back each time the frame reloads. A saved edit remounts the inspector,
47
+ * once for the edit and again when the file watcher reports the write, and each
48
+ * remount replaces the control that held focus and drops it to the page. It
49
+ * holds until focus lands anywhere else.
46
50
  */
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
- }
51
+ let refocus: string | undefined
61
52
 
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
53
+ function swatchLabel(label: string): string {
54
+ return `${label} picker`
69
55
  }
70
56
 
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
- )
57
+ function tokensLabel(label: string): string {
58
+ return `${label} tokens`
143
59
  }
144
60
 
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`
61
+ function eyedropperLabel(label: string): string {
62
+ return `${label} eyedropper`
156
63
  }
157
64
 
158
65
  if (typeof document !== 'undefined') {
@@ -161,28 +68,87 @@ if (typeof document !== 'undefined') {
161
68
  const target = event.target
162
69
  const name =
163
70
  target instanceof Element ? target.getAttribute('aria-label') : null
164
- if (name !== swatchLabel(refocus)) refocus = undefined
71
+ if (name !== refocus) refocus = undefined
165
72
  })
166
73
  }
167
74
 
75
+ /** The screen sampler, where the browser ships one. */
76
+ function eyeDropper(): (new () => EyeDropperSampler) | undefined {
77
+ return typeof window === 'undefined' ? undefined : window.EyeDropper
78
+ }
79
+
80
+ type Open = 'picker' | 'tokens' | undefined
81
+
82
+ const GAP = 8
83
+
168
84
  /**
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.
85
+ * The left edge of the scrolling panel holding the row, so a popover clears
86
+ * the panel's padding rather than overlapping it. The row's own edge stands in
87
+ * where no ancestor scrolls.
88
+ */
89
+ function panelLeft(row: HTMLElement): number {
90
+ for (let node = row.parentElement; node; node = node.parentElement) {
91
+ const overflow = getComputedStyle(node).overflowY
92
+ if (overflow === 'auto' || overflow === 'scroll') {
93
+ return node.getBoundingClientRect().left
94
+ }
95
+ }
96
+ return row.getBoundingClientRect().left
97
+ }
98
+
99
+ /**
100
+ * Places a popover beside the row, to the left of the panel, and holds it
101
+ * inside the window. The panel scrolls and clips, so the popover sits in the
102
+ * top layer where the browser has one and at fixed coordinates either way.
103
+ */
104
+ function place(popover: HTMLElement, row: HTMLElement): void {
105
+ if (
106
+ typeof popover.showPopover === 'function' &&
107
+ !popover.matches(':popover-open')
108
+ ) {
109
+ popover.showPopover()
110
+ }
111
+ const box = popover.getBoundingClientRect()
112
+ const anchor = row.getBoundingClientRect()
113
+ const width = document.documentElement.clientWidth || window.innerWidth
114
+ const height = document.documentElement.clientHeight || window.innerHeight
115
+ const before = panelLeft(row) - GAP - box.width
116
+ const left =
117
+ before >= GAP
118
+ ? before
119
+ : Math.max(GAP, Math.min(anchor.right + GAP, width - box.width - GAP))
120
+ const top = Math.max(GAP, Math.min(anchor.top, height - box.height - GAP))
121
+ popover.style.left = `${left}px`
122
+ popover.style.top = `${top}px`
123
+ }
124
+
125
+ /**
126
+ * One color property: a swatch opening the picker, the hex, the opacity, a
127
+ * tokens icon opening the project's color tokens, and an eyedropper where the
128
+ * browser has one. A token keeps following the theme and a literal does not,
129
+ * so the tokens sit one press from the row.
172
130
  */
173
131
  export function ColorField({
174
132
  label,
175
133
  color,
176
134
  text,
135
+ inline,
177
136
  painted,
178
137
  tokens,
179
138
  paint,
139
+ owner,
180
140
  isRaw,
181
141
  isBusy,
142
+ preview,
182
143
  onCommit,
183
144
  }: ColorFieldProps): JSX.Element {
184
- const [isOpen, setOpen] = useState(false)
145
+ const [open, setOpen] = useState<Open>(() =>
146
+ isPickerHeld(owner) ? 'picker' : undefined,
147
+ )
148
+ const root = useRef<HTMLDivElement>(null)
149
+ const row = useRef<HTMLDivElement>(null)
185
150
  const swatch = useRef<HTMLButtonElement>(null)
151
+ const tokenButton = useRef<HTMLButtonElement>(null)
186
152
  const list = useRef<HTMLUListElement>(null)
187
153
  const dialog = useRef<HTMLDivElement>(null)
188
154
  const picked = color?.kind === 'token' ? tokens.indexOf(color.name) : -1
@@ -191,31 +157,32 @@ export function ColorField({
191
157
 
192
158
  /*
193
159
  * 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.
160
+ * after the one that opened the list reaches it, not the button.
195
161
  */
196
162
  useLayoutEffect(() => {
197
- if (!isOpen) return
198
- const target =
199
- list.current ??
200
- dialog.current?.querySelector<HTMLInputElement>('input') ??
201
- null
202
- target?.focus()
203
- }, [isOpen])
163
+ if (!open || !dialog.current || !row.current) return
164
+ place(dialog.current, row.current)
165
+ if (open === 'tokens') (list.current ?? dialog.current).focus()
166
+ }, [open])
204
167
 
205
168
  useLayoutEffect(() => {
206
- if (refocus === label) swatch.current?.focus()
169
+ if (refocus === undefined) return
170
+ root.current
171
+ ?.querySelector<HTMLElement>(`[aria-label="${refocus}"]`)
172
+ ?.focus()
207
173
  }, [label])
208
174
 
209
175
  const close = () => {
210
- setOpen(false)
211
- swatch.current?.focus()
176
+ const trigger = open === 'tokens' ? tokenButton : swatch
177
+ releasePicker()
178
+ setOpen(undefined)
179
+ trigger.current?.focus()
212
180
  }
213
181
 
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)
182
+ const toggle = (next: Exclude<Open, undefined>) => {
183
+ releasePicker()
184
+ setActive(Math.max(0, picked))
185
+ setOpen(open === next ? undefined : next)
219
186
  }
220
187
 
221
188
  const pick = (name: string) => {
@@ -223,24 +190,55 @@ export function ColorField({
223
190
  close()
224
191
  return
225
192
  }
226
- commitFromPicker(
193
+ close()
194
+ refocus = tokensLabel(label)
195
+ onCommit(
227
196
  composeColor({ kind: 'token', name, opacity: currentOpacity(color) }),
228
197
  )
229
198
  }
230
199
 
200
+ const Sampler = eyeDropper()
201
+ const sample = async (sampler: new () => EyeDropperSampler) => {
202
+ let picked: string
203
+ try {
204
+ picked = (await new sampler().open()).sRGBHex
205
+ } catch {
206
+ /* The operator dismissed the sampler, which writes nothing. */
207
+ return
208
+ }
209
+ const hex = readHex(picked)
210
+ if (!hex) return
211
+ refocus = eyedropperLabel(label)
212
+ onCommit(
213
+ composeColor({
214
+ kind: 'hex',
215
+ hex: hex.hex,
216
+ opacity: currentOpacity(color),
217
+ }),
218
+ )
219
+ }
220
+
231
221
  const id = `color-${label.replace(/\W+/g, '-')}`
232
222
 
233
223
  return (
234
224
  <div
225
+ ref={root}
235
226
  class="color-field is-wide"
236
227
  onFocusOut={(event) => {
237
- const next = event.relatedTarget
238
- if (!(next instanceof Node) || !event.currentTarget.contains(next)) {
239
- setOpen(false)
240
- }
228
+ const field = event.currentTarget
229
+ /*
230
+ * Read once focus has settled. A reload removes the field while it
231
+ * holds focus, and a picker held across that reload must stay open.
232
+ */
233
+ setTimeout(() => {
234
+ if (!field.isConnected) return
235
+ if (field.contains(document.activeElement)) return
236
+ releasePicker()
237
+ setOpen(undefined)
238
+ }, 0)
241
239
  }}
242
240
  >
243
- <div class="fill-row">
241
+ <div ref={row} class="fill-row">
244
242
  <div class="glyph-field">
245
243
  <button
246
244
  ref={swatch}
@@ -248,13 +246,10 @@ export function ColorField({
248
246
  type="button"
249
247
  aria-label={swatchLabel(label)}
250
248
  aria-haspopup="dialog"
251
- aria-expanded={isOpen}
249
+ aria-expanded={open === 'picker'}
252
250
  title={label}
253
251
  disabled={isBusy}
254
- onClick={() => {
255
- setActive(Math.max(0, picked))
256
- setOpen(!isOpen)
257
- }}
252
+ onClick={() => toggle('picker')}
258
253
  >
259
254
  <span
260
255
  class="swatch-paint"
@@ -270,6 +265,38 @@ export function ColorField({
270
265
  onCommit={onCommit}
271
266
  />
272
267
  </div>
268
+ <button
269
+ ref={tokenButton}
270
+ class="color-icon"
271
+ type="button"
272
+ aria-label={tokensLabel(label)}
273
+ aria-haspopup="dialog"
274
+ aria-expanded={open === 'tokens'}
275
+ title="Pick a color token"
276
+ disabled={isBusy}
277
+ onClick={() => toggle('tokens')}
278
+ >
279
+ <svg viewBox="0 0 16 16" aria-hidden="true">
280
+ <circle cx="5" cy="5" r="2.25" />
281
+ <circle cx="11" cy="5" r="2.25" />
282
+ <circle cx="5" cy="11" r="2.25" />
283
+ <circle cx="11" cy="11" r="2.25" />
284
+ </svg>
285
+ </button>
286
+ {Sampler ? (
287
+ <button
288
+ class="color-icon"
289
+ type="button"
290
+ aria-label={eyedropperLabel(label)}
291
+ title="Pick a color from the screen"
292
+ disabled={isBusy}
293
+ onClick={() => void sample(Sampler)}
294
+ >
295
+ <svg viewBox="0 0 16 16" aria-hidden="true">
296
+ <path d="M10.5 2.5a1.4 1.4 0 0 1 2 0l1 1a1.4 1.4 0 0 1 0 2L12 7l.5.5-1 1-4-4 1-1L9 4zM7.5 5.5l3 3-5 5H2.5v-3z" />
297
+ </svg>
298
+ </button>
299
+ ) : null}
273
300
  {isRaw ? (
274
301
  <span
275
302
  class="raw"
@@ -279,12 +306,17 @@ export function ColorField({
279
306
  </span>
280
307
  ) : null}
281
308
  </div>
282
- {isOpen ? (
309
+ {open ? (
283
310
  <div
284
311
  ref={dialog}
285
- class="color-picker"
312
+ class={
313
+ open === 'picker' ? 'color-popover' : 'color-popover is-tokens'
314
+ }
315
+ popover="manual"
286
316
  role="dialog"
287
- aria-label={`${label} colors`}
317
+ aria-label={
318
+ open === 'picker' ? `${label} color` : `${label} token list`
319
+ }
288
320
  tabIndex={-1}
289
321
  onKeyDown={(event) => {
290
322
  if (event.key !== 'Escape') return
@@ -296,7 +328,18 @@ export function ColorField({
296
328
  close()
297
329
  }}
298
330
  >
299
- {tokens.length > 0 ? (
331
+ {open === 'picker' ? (
332
+ <ColorPicker
333
+ owner={owner}
334
+ color={color}
335
+ text={text}
336
+ inline={inline}
337
+ painted={painted}
338
+ begin={preview}
339
+ isBusy={isBusy}
340
+ onCommit={onCommit}
341
+ />
342
+ ) : tokens.length > 0 ? (
300
343
  <ul
301
344
  ref={list}
302
345
  class="token-list"
@@ -348,19 +391,6 @@ export function ColorField({
348
391
  No color tokens resolve. Add them to the token sheet
349
392
  </p>
350
393
  )}
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
394
  </div>
365
395
  ) : null}
366
396
  </div>