@erclx/canon 5.28.0 → 5.30.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 (81) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/review-ui/references/post.md +7 -5
  3. package/docs/agents/canvas.md +8 -0
  4. package/docs/agents/pr-evidence.md +7 -7
  5. package/package.json +1 -1
  6. package/src/canvas/client/app.tsx +105 -4
  7. package/src/canvas/client/inspector.tsx +13 -4
  8. package/src/canvas/client/keys.ts +23 -6
  9. package/src/canvas/client/main.tsx +9 -0
  10. package/src/canvas/client/pages-panel.tsx +5 -1
  11. package/src/canvas/client/panel-handle.tsx +90 -0
  12. package/src/canvas/client/shell.css +86 -2
  13. package/src/canvas/client/state.ts +159 -6
  14. package/src/canvas/client/surface.tsx +37 -1
  15. package/src/canvas/client/tools.tsx +91 -2
  16. package/src/canvas/content.ts +65 -0
  17. package/src/canvas/edit.ts +195 -23
  18. package/src/canvas/history.ts +116 -0
  19. package/src/canvas/server.ts +126 -13
  20. package/src/{exec.ts → cli/exec.ts} +1 -1
  21. package/src/{help.ts → cli/help.ts} +1 -1
  22. package/src/cli.ts +1 -1
  23. package/src/commands/audits.ts +9 -1
  24. package/src/commands/autoship.ts +1 -1
  25. package/src/commands/canvas.ts +1 -1
  26. package/src/commands/capture.ts +1 -1
  27. package/src/commands/census.ts +1 -1
  28. package/src/commands/claude.ts +2 -2
  29. package/src/commands/comments.ts +1 -1
  30. package/src/commands/context.ts +1 -1
  31. package/src/commands/demo.ts +9 -1
  32. package/src/commands/deps.ts +1 -1
  33. package/src/commands/design.ts +9 -1
  34. package/src/commands/docs.ts +1 -1
  35. package/src/commands/driver.ts +9 -1
  36. package/src/commands/feedback.ts +1 -1
  37. package/src/commands/gate.ts +1 -1
  38. package/src/commands/gov.ts +1 -1
  39. package/src/commands/indexes.ts +1 -1
  40. package/src/commands/init.ts +10 -2
  41. package/src/commands/intake.ts +1 -1
  42. package/src/commands/inventory.ts +9 -1
  43. package/src/commands/labels.ts +1 -1
  44. package/src/commands/markdown.ts +1 -1
  45. package/src/commands/migrate.ts +1 -1
  46. package/src/commands/pr.ts +141 -2
  47. package/src/commands/records.ts +1 -1
  48. package/src/commands/repo.ts +1 -1
  49. package/src/commands/sandbox.ts +1 -1
  50. package/src/commands/secrets.ts +9 -1
  51. package/src/commands/sessions.ts +1 -1
  52. package/src/commands/slides.ts +1 -1
  53. package/src/commands/standards.ts +1 -1
  54. package/src/commands/sync.ts +2 -2
  55. package/src/commands/targets.ts +1 -1
  56. package/src/commands/tasks.ts +1 -1
  57. package/src/commands/teach.ts +1 -1
  58. package/src/commands/tooling.ts +1 -1
  59. package/src/commands/transcripts.ts +1 -1
  60. package/src/commands/upgrade.ts +1 -1
  61. package/src/commands/wiki.ts +1 -1
  62. package/src/commands/worktrees.ts +1 -1
  63. package/src/design/brand-favicon.ts +18 -0
  64. package/src/gate/measures.ts +1 -1
  65. package/src/init/run.ts +1 -1
  66. package/src/migrate/rename.ts +1 -1
  67. package/src/pr/evidence.ts +41 -7
  68. package/src/roots/project.ts +1 -1
  69. package/src/sandbox/headless/run.ts +1 -1
  70. package/src/sandbox/provision.ts +1 -1
  71. package/src/sandbox/scenario.ts +1 -1
  72. package/src/serve/report.ts +1 -1
  73. package/src/sync/engine.ts +1 -1
  74. package/src/sync/git.ts +1 -1
  75. package/src/sync/workflow.ts +1 -1
  76. package/src/targets/validate.ts +1 -1
  77. package/src/teach/nav.ts +5 -12
  78. package/src/tooling/inject.ts +1 -1
  79. package/src/tooling/verify-stack.ts +1 -1
  80. /package/src/{cli-run.ts → cli/run.ts} +0 -0
  81. /package/src/{ui.ts → cli/ui.ts} +0 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.28.0",
4
+ "version": "5.30.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -25,11 +25,11 @@ Write it to `.canon/tmp/pr/review-ui/body-<number>-<short-sha>.md` at the main w
25
25
 
26
26
  P passed, F failed, E need eyes, N not driven. Drove <address> at <short-sha>, rendered by <renderer>.
27
27
 
28
- | Box | Verdict | Evidence |
29
- | ---------------------------------- | ---------- | --------------------------------------------- |
30
- | 1. <the box as written, shortened> | fail | <the value read against what the box expects> |
31
- | 2. <box> | needs eyes | <what the frame showed, in words> |
32
- | 3. <box> | pass | Evidence `<stem>` <what it showed, in words> |
28
+ | Box | Evidence |
29
+ | -------------------------------------------- | --------------------------------------------- |
30
+ | **Fail.** 1. <the box as written, shortened> | <the value read against what the box expects> |
31
+ | **Needs eyes.** 2. <box> | <what the frame showed, in words> |
32
+ | **Pass.** 3. <box> | Evidence `<stem>` <what it showed, in words> |
33
33
 
34
34
  **Frames**
35
35
 
@@ -50,6 +50,8 @@ P passed, F failed, E need eyes, N not driven. Drove <address> at <short-sha>, r
50
50
  <!-- review-ui: head=<head> -->
51
51
  ```
52
52
 
53
+ The table has two columns and each Box cell opens on its verdict in bold with a period: `**Pass.**`, `**Fail.**`, `**Needs eyes.**`, or `**Not driven.**`. A column holding only those short words loses its width beside two long ones and GitHub breaks its header mid-word, so the verdict rides in the Box cell instead.
54
+
53
55
  An Evidence cell resting on a frame holds words only, so the table's text columns keep the body's width. A frame Step 5 pushed goes in the Frames block below the table, one `Box <n>, <verdict>` label then the `![](<link>)` embed for each framed box, with the link exactly as `canon pr frames` returned it. It names the frame's path on the frames branch, which stays put while other pull requests' frames are dropped and carries this pass's stamp, so a later pass never changes the image.
54
56
 
55
57
  The row still says what the frame showed, so it reads without the image. A state the worker's evidence already shows names the evidence stem in backticks and embeds nothing, since the evidence comment carries that image. A push that refused leaves the words alone and names the reason. Omit the Frames block when no box pushed a frame. Never cite the local frame path, since a path under `.canon/` is a board identifier the label scan rejects.
@@ -53,6 +53,14 @@ Inline style beats a class, so an operator's edit masks a class change Claude ma
53
53
 
54
54
  The Theme tab in the shell lists what the token stylesheet defines, grouped as color, spacing, radius, font family, font size, and other. The page record at `/api/pages` carries the same list as `tokens.groups`.
55
55
 
56
+ ## Undo in the shell
57
+
58
+ The operator undoes and redoes their own shell edits, frame moves, and frame resizes from Ctrl or Cmd with Z, Shift with it or Ctrl with Y, or the tool strip's buttons. The history lives in the `canvas serve` process and covers shell edits alone, so a `canvas edit`, `canvas frame move`, or `canvas frame resize` never enters it, and their `--json` records are unchanged. A restart clears it.
59
+
60
+ An undo writes the recorded state back only while the element or frame still holds what the operator's edit left. When Claude has since rewritten what that edit changed, being the element's inline style or, for a text edit, its text, or has added or removed an element in that frame, the operator's entry is dropped and nothing is written, and the shell says so. A write that changes another element's style or text, or another part of the same element, leaves the entry live. The page record at `/api/pages` carries `history` as `{ canUndo, canRedo }`.
61
+
62
+ The tool strip also holds a toggle, bound to backslash, that hides both side panels to show the whole surface, and each panel resizes from a handle on its inner edge. Both are remembered in the browser and write nothing to the project.
63
+
56
64
  ## Mark a frame being edited
57
65
 
58
66
  `canvas editing <page>/<frame>` marks a frame as being edited, so an open canvas shows who is working on it before the write lands. The shell draws the mark as `<by> editing` on that frame's label with a dashed outline around it, and drops it on the reread after `--done` or at the mark's `until` with no reread. Run it before writing a frame and again with `--done` once the edits are written. A bare `canvas editing` lists the live marks, and `--json` emits `{ ok, editing }` after each of the three, with each mark carrying `page`, `frame`, `by`, `since`, and `until`.
@@ -42,7 +42,7 @@ directory under the `evidence/` segment, and within a state by filename stem,
42
42
  so a `before/hero.png` and an `after/hero.png` naming the same case group as
43
43
  one entry, not two files. Each entry carries whether it existed at the base
44
44
  commit, so the comment shows a base image or marks it new. All-new states drop
45
- the Base column and each image is an `<img width="1000">`.
45
+ the Base column and each `<img>` takes its PNG width, at most 1000.
46
46
 
47
47
  `reason` on the record is what a caller branches on, not the exit code:
48
48
 
@@ -85,9 +85,10 @@ unreadable thread refuses as `gh-failed` on both reasons rather than
85
85
  reporting the fields absent.
86
86
 
87
87
  `ok` also carries `states`, the comparison the body renders as data: each
88
- state's name and its items, each with its `path`, `stem`, and whether it was
89
- `added`. `review-ui` matches a frame against it so it publishes no image the
88
+ state's items with `path`, `stem`, `added`, and `width` and `baseWidth` when
89
+ read. `review-ui` matches a frame against it so it publishes no image the
90
90
  worker's evidence already shows. `canon docs pr-frames` covers that store.
91
+ `widthUnread` lists a failed read as `head:<path>` or `base:<path>`.
91
92
 
92
93
  ## What `[number]` selects, and what it does not
93
94
 
@@ -243,12 +244,11 @@ or `--checklist` with it refuses as `check-writes` before any `gh` call, since
243
244
  those ask a read to write.
244
245
 
245
246
  The thread cannot separate a skipped preview step from a failed one. A pull
246
- request whose deploy run failed or timed out reads as owing a preview, and the
247
+ request whose deploy failed or timed out reads as owing a preview, and the
247
248
  session that ran it holds the cause. A checklist-only branch posts a marked
248
249
  comment, so it reads as owing a preview once that comment exists and a deploy
249
- workflow resolves. The deploy
250
- workflow is read from the checkout rather than the pull request's head, so a
251
- pull request that adds or removes its own deploy workflow reads against trunk's.
250
+ workflow resolves. The deploy workflow is read from the checkout rather than
251
+ the pull request's head, so one adding or removing its own reads trunk's.
252
252
 
253
253
  An unreadable thread refuses as `gh-failed` rather than reporting `settled` off
254
254
  a thread it never read. The check skips the merge base read, which only the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.28.0",
4
+ "version": "5.30.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,9 +1,20 @@
1
1
  /** @jsxImportSource preact */
2
+ import { effect } from '@preact/signals'
2
3
  import type { JSX } from 'preact'
3
- import { useRef } from 'preact/hooks'
4
+ import { useLayoutEffect, useRef } from 'preact/hooks'
4
5
  import { Inspector } from '@/canvas/client/inspector'
5
6
  import { PagesPanel } from '@/canvas/client/pages-panel'
6
- import { currentPage, focusFrame, tokens } from '@/canvas/client/state'
7
+ import { PanelHandle } from '@/canvas/client/panel-handle'
8
+ import {
9
+ clampPanelWidth,
10
+ currentPage,
11
+ DEFAULT_PANEL_WIDTHS,
12
+ focusFrame,
13
+ type PanelWidths,
14
+ panelsHidden,
15
+ panelWidths,
16
+ tokens,
17
+ } from '@/canvas/client/state'
7
18
  import { Surface } from '@/canvas/client/surface'
8
19
 
9
20
  const SOURCE_LABEL = {
@@ -66,22 +77,112 @@ function PageDetails(): JSX.Element | null {
66
77
  )
67
78
  }
68
79
 
80
+ const PANELS_KEY = 'canon-canvas-panels'
81
+
82
+ interface StoredPanels extends PanelWidths {
83
+ readonly hidden: boolean
84
+ }
85
+
86
+ function storedWidth(value: unknown, fallback: number): number {
87
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback
88
+ }
89
+
90
+ /** What the last visit left, with anything unreadable read as the default. */
91
+ function readStoredPanels(): StoredPanels {
92
+ const fallback = { hidden: false, ...DEFAULT_PANEL_WIDTHS }
93
+ try {
94
+ const parsed: unknown = JSON.parse(localStorage.getItem(PANELS_KEY) ?? '{}')
95
+ if (typeof parsed !== 'object' || parsed === null) return fallback
96
+ const record = parsed as Record<string, unknown>
97
+ return {
98
+ hidden: record.hidden === true,
99
+ left: storedWidth(record.left, fallback.left),
100
+ right: storedWidth(record.right, fallback.right),
101
+ }
102
+ } catch {
103
+ return fallback
104
+ }
105
+ }
106
+
107
+ function storePanels(panels: StoredPanels): void {
108
+ try {
109
+ localStorage.setItem(PANELS_KEY, JSON.stringify(panels))
110
+ } catch {
111
+ /* A blocked store costs the remembered layout, nothing else. */
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Holds both widths inside this window, the details panel giving way first,
117
+ * since long layer names are what the pages panel widens for.
118
+ */
119
+ function fitWidths(widths: PanelWidths): PanelWidths {
120
+ const right = clampPanelWidth(
121
+ 'right',
122
+ widths.right,
123
+ window.innerWidth,
124
+ widths,
125
+ )
126
+ const left = clampPanelWidth('left', widths.left, window.innerWidth, {
127
+ ...widths,
128
+ right,
129
+ })
130
+ return { left, right }
131
+ }
132
+
133
+ /**
134
+ * Restores the panels the last visit left and stores each change, the way the
135
+ * theme pick is kept. The read lands before the first paint, so a hidden
136
+ * layout never flashes open. The widths are clamped on that read and on every
137
+ * window resize, since a width kept from a wider window could squeeze the
138
+ * surface out.
139
+ */
140
+ function usePanelPreference(): void {
141
+ useLayoutEffect(() => {
142
+ const stored = readStoredPanels()
143
+ panelsHidden.value = stored.hidden
144
+ panelWidths.value = fitWidths(stored)
145
+ const handleResize = () => {
146
+ const fitted = fitWidths(panelWidths.value)
147
+ const { left, right } = panelWidths.value
148
+ if (fitted.left !== left || fitted.right !== right) {
149
+ panelWidths.value = fitted
150
+ }
151
+ }
152
+ window.addEventListener('resize', handleResize)
153
+ const stopStoring = effect(() =>
154
+ storePanels({ hidden: panelsHidden.value, ...panelWidths.value }),
155
+ )
156
+ return () => {
157
+ window.removeEventListener('resize', handleResize)
158
+ stopStoring()
159
+ }
160
+ }, [])
161
+ }
162
+
69
163
  export function App(): JSX.Element {
70
164
  const viewportRef = useRef<HTMLDivElement>(null)
165
+ usePanelPreference()
166
+ const { left, right } = panelWidths.value
71
167
  return (
72
- <div class="shell">
168
+ <div
169
+ class={panelsHidden.value ? 'shell panels-hidden' : 'shell'}
170
+ style={{ '--panel-left': `${left}px`, '--panel-right': `${right}px` }}
171
+ >
73
172
  <PagesPanel
74
173
  onFocusFrame={(frame) => {
75
174
  const rect = viewportRef.current?.getBoundingClientRect()
76
175
  focusFrame(frame, rect?.width ?? 0, rect?.height ?? 0)
77
176
  }}
78
177
  />
178
+ <PanelHandle side="left" controls="pages-panel" />
79
179
  <Surface viewportRef={viewportRef} />
80
- <aside class="panel panel-right" aria-label="Details">
180
+ <aside id="details-panel" class="panel panel-right" aria-label="Details">
81
181
  <Inspector />
82
182
  <PageDetails />
83
183
  <TokenDetails />
84
184
  </aside>
185
+ <PanelHandle side="right" controls="details-panel" />
85
186
  </div>
86
187
  )
87
188
  }
@@ -34,6 +34,7 @@ import {
34
34
  frameDocuments,
35
35
  frameKey,
36
36
  frameVersions,
37
+ newStep,
37
38
  pendingEdit,
38
39
  savedEdit,
39
40
  selectedElement,
@@ -240,17 +241,25 @@ function ElementFields({
240
241
  }
241
242
  /*
242
243
  * The writer takes one property an edit, so a control setting two posts
243
- * them in turn and stops at the first refusal. Each carries the hash the
244
- * last answered with in its own address, since a frame reloading between
245
- * 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.
246
247
  */
247
248
  const commitAll = async (
248
249
  changes: readonly (readonly [string, string])[],
249
250
  ): Promise<void> => {
251
+ const step = newStep()
250
252
  let current = address
251
253
  for (const [property, value] of changes) {
252
254
  if (!current) return
253
- 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
+ )
254
263
  if (!written) return
255
264
  if (written.hash) current = { ...current, hash: written.hash }
256
265
  }
@@ -12,6 +12,9 @@ export type SurfaceAction =
12
12
  | 'zoom-fit'
13
13
  | 'pan-hold'
14
14
  | 'pan-release'
15
+ | 'undo'
16
+ | 'redo'
17
+ | 'panels-toggle'
15
18
 
16
19
  /** The fields of a `KeyboardEvent` the map reads, so a test needs no DOM. */
17
20
  export interface KeyInput {
@@ -34,21 +37,35 @@ const PLAIN_KEYS: Readonly<Record<string, SurfaceAction | undefined>> = {
34
37
  }
35
38
 
36
39
  /**
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.
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.
42
57
  */
43
58
  export function surfaceAction(event: KeyInput): SurfaceAction | undefined {
44
59
  if (event.type === 'keyup') {
45
60
  return event.key === ' ' ? 'pan-release' : undefined
46
61
  }
47
62
  if (event.type !== 'keydown') return undefined
48
- if (event.ctrlKey || event.metaKey || event.altKey) return undefined
63
+ if (event.ctrlKey || event.metaKey) return historyAction(event)
64
+ if (event.altKey) return undefined
49
65
  if (event.shiftKey) {
50
66
  if (event.code === 'Digit1') return 'zoom-fit'
51
67
  return event.key === '+' ? 'zoom-in' : undefined
52
68
  }
69
+ if (event.code === 'Backslash') return 'panels-toggle'
53
70
  return PLAIN_KEYS[event.key.toLowerCase()]
54
71
  }
@@ -51,6 +51,15 @@ chrome.rel = 'stylesheet'
51
51
  chrome.href = '/api/chrome.css'
52
52
  document.head.prepend(chrome)
53
53
 
54
+ /*
55
+ * The page declares an empty icon so the browser never asks for /favicon.ico,
56
+ * since the bundler cannot resolve a route the server answers, and this points
57
+ * it at the brand icon once the shell loads.
58
+ */
59
+ document
60
+ .querySelector('link[rel="icon"]')
61
+ ?.setAttribute('href', '/api/icon.svg')
62
+
54
63
  const events = new EventSource('/api/events')
55
64
  events.addEventListener('message', (message) => {
56
65
  void applyChange(JSON.parse(message.data) as ChangeEvent)
@@ -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 />
@@ -0,0 +1,90 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { useRef } from 'preact/hooks'
4
+ import {
5
+ clampPanelWidth,
6
+ MAX_PANEL_WIDTH,
7
+ MIN_PANEL_WIDTH,
8
+ type PanelSide,
9
+ panelWidths,
10
+ setPanelWidth,
11
+ } from '@/canvas/client/state'
12
+
13
+ const KEY_STEP = 16
14
+ const SHIFT_STEP = 64
15
+
16
+ const NAMES: Readonly<Record<PanelSide, string>> = {
17
+ left: 'Resize pages panel',
18
+ right: 'Resize details panel',
19
+ }
20
+
21
+ /**
22
+ * The drag handle on a panel's inner edge, a focusable separator the arrow
23
+ * keys move as well. It sits outside the surface, so a drag on it never pans,
24
+ * moves a frame, or reaches the undo history.
25
+ */
26
+ export function PanelHandle({
27
+ side,
28
+ controls,
29
+ }: {
30
+ readonly side: PanelSide
31
+ readonly controls: string
32
+ }): JSX.Element {
33
+ const drag = useRef<{ x: number; width: number } | undefined>(undefined)
34
+ const width = panelWidths.value[side]
35
+ /* Dragging right widens the left panel and narrows the right one. */
36
+ const sign = side === 'left' ? 1 : -1
37
+
38
+ const handlePointerDown = (event: PointerEvent) => {
39
+ event.preventDefault()
40
+ drag.current = { x: event.clientX, width }
41
+ ;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
42
+ }
43
+
44
+ const handlePointerMove = (event: PointerEvent) => {
45
+ const start = drag.current
46
+ if (!start) return
47
+ setPanelWidth(
48
+ side,
49
+ start.width + sign * (event.clientX - start.x),
50
+ window.innerWidth,
51
+ )
52
+ }
53
+
54
+ const handlePointerUp = () => {
55
+ drag.current = undefined
56
+ }
57
+
58
+ const handleKeyDown = (event: KeyboardEvent) => {
59
+ const step = event.shiftKey ? SHIFT_STEP : KEY_STEP
60
+ const next: Record<string, number | undefined> = {
61
+ ArrowRight: width + sign * step,
62
+ ArrowLeft: width - sign * step,
63
+ Home: MIN_PANEL_WIDTH,
64
+ End: MAX_PANEL_WIDTH,
65
+ }
66
+ const wanted = next[event.key]
67
+ if (wanted === undefined) return
68
+ event.preventDefault()
69
+ setPanelWidth(side, wanted, window.innerWidth)
70
+ }
71
+
72
+ return (
73
+ <div
74
+ class={`panel-handle panel-handle-${side}`}
75
+ role="separator"
76
+ aria-orientation="vertical"
77
+ aria-label={NAMES[side]}
78
+ aria-controls={controls}
79
+ aria-valuenow={width}
80
+ aria-valuemin={MIN_PANEL_WIDTH}
81
+ aria-valuemax={clampPanelWidth(side, MAX_PANEL_WIDTH, window.innerWidth)}
82
+ tabIndex={0}
83
+ onPointerDown={handlePointerDown}
84
+ onPointerMove={handlePointerMove}
85
+ onPointerUp={handlePointerUp}
86
+ onPointerCancel={handlePointerUp}
87
+ onKeyDown={handleKeyDown}
88
+ />
89
+ )
90
+ }
@@ -38,11 +38,58 @@ code {
38
38
  }
39
39
 
40
40
  .shell {
41
+ position: relative;
41
42
  display: grid;
42
- grid-template-columns: 15rem minmax(0, 1fr) 16rem;
43
+ grid-template-columns: var(--panel-left, 15rem) minmax(0, 1fr) var(
44
+ --panel-right,
45
+ 16rem
46
+ );
43
47
  height: 100%;
44
48
  }
45
49
 
50
+ /*
51
+ * Centered on the edge, so a 24 pixel circle around it stays inside the
52
+ * panel's padding on one side and short of the tool strip on the other, with
53
+ * a line drawn at the edge itself.
54
+ */
55
+ .panel-handle {
56
+ position: absolute;
57
+ top: 0;
58
+ bottom: 0;
59
+ z-index: 2;
60
+ width: 12px;
61
+ cursor: col-resize;
62
+ touch-action: none;
63
+ }
64
+
65
+ .panel-handle-left {
66
+ left: calc(var(--panel-left, 15rem) - 6px);
67
+ }
68
+
69
+ .panel-handle-right {
70
+ right: calc(var(--panel-right, 16rem) - 6px);
71
+ }
72
+
73
+ .panel-handle::after {
74
+ content: '';
75
+ position: absolute;
76
+ top: 0;
77
+ bottom: 0;
78
+ left: 5px;
79
+ width: 2px;
80
+ background: transparent;
81
+ }
82
+
83
+ .panel-handle:hover::after,
84
+ .panel-handle:active::after,
85
+ .panel-handle:focus-visible::after {
86
+ background: var(--color-accent);
87
+ }
88
+
89
+ .shell.panels-hidden > .panel-handle {
90
+ display: none;
91
+ }
92
+
46
93
  .panel {
47
94
  overflow-y: auto;
48
95
  padding: var(--space-md);
@@ -531,6 +578,32 @@ code {
531
578
  background: var(--color-border);
532
579
  }
533
580
 
581
+ .tool-button:disabled {
582
+ opacity: 0.4;
583
+ background: transparent;
584
+ cursor: not-allowed;
585
+ }
586
+
587
+ .tools-divider {
588
+ height: 1px;
589
+ margin: 2px var(--space-xs);
590
+ background: var(--color-border);
591
+ }
592
+
593
+ .history-notice {
594
+ position: absolute;
595
+ top: var(--space-md);
596
+ left: 50%;
597
+ max-width: min(28rem, calc(100% - 8rem));
598
+ margin: 0;
599
+ padding: var(--space-xs) var(--space-sm);
600
+ border: 1px solid var(--color-border);
601
+ border-radius: var(--radius-panel);
602
+ background: var(--color-chrome);
603
+ color: var(--color-text);
604
+ transform: translateX(-50%);
605
+ }
606
+
534
607
  .zoom {
535
608
  min-width: 3.5rem;
536
609
  text-align: center;
@@ -543,7 +616,8 @@ code {
543
616
  grid-template-columns: 12rem minmax(0, 1fr);
544
617
  }
545
618
 
546
- .panel-right {
619
+ .panel-right,
620
+ .panel-handle {
547
621
  display: none;
548
622
  }
549
623
  }
@@ -561,6 +635,16 @@ code {
561
635
  }
562
636
  }
563
637
 
638
+ /* Two classes outrank every width's own columns, so hiding holds at each. */
639
+ .shell.panels-hidden {
640
+ grid-template-columns: minmax(0, 1fr);
641
+ grid-template-rows: minmax(0, 1fr);
642
+ }
643
+
644
+ .shell.panels-hidden > .panel {
645
+ display: none;
646
+ }
647
+
564
648
  .frame-row {
565
649
  display: flex;
566
650
  align-items: center;