@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/review-ui/references/post.md +7 -5
- package/docs/agents/canvas.md +8 -0
- package/docs/agents/pr-evidence.md +7 -7
- package/package.json +1 -1
- package/src/canvas/client/app.tsx +105 -4
- package/src/canvas/client/inspector.tsx +13 -4
- package/src/canvas/client/keys.ts +23 -6
- package/src/canvas/client/main.tsx +9 -0
- package/src/canvas/client/pages-panel.tsx +5 -1
- package/src/canvas/client/panel-handle.tsx +90 -0
- package/src/canvas/client/shell.css +86 -2
- package/src/canvas/client/state.ts +159 -6
- package/src/canvas/client/surface.tsx +37 -1
- package/src/canvas/client/tools.tsx +91 -2
- package/src/canvas/content.ts +65 -0
- package/src/canvas/edit.ts +195 -23
- package/src/canvas/history.ts +116 -0
- package/src/canvas/server.ts +126 -13
- package/src/{exec.ts → cli/exec.ts} +1 -1
- package/src/{help.ts → cli/help.ts} +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/audits.ts +9 -1
- package/src/commands/autoship.ts +1 -1
- package/src/commands/canvas.ts +1 -1
- package/src/commands/capture.ts +1 -1
- package/src/commands/census.ts +1 -1
- package/src/commands/claude.ts +2 -2
- package/src/commands/comments.ts +1 -1
- package/src/commands/context.ts +1 -1
- package/src/commands/demo.ts +9 -1
- package/src/commands/deps.ts +1 -1
- package/src/commands/design.ts +9 -1
- package/src/commands/docs.ts +1 -1
- package/src/commands/driver.ts +9 -1
- package/src/commands/feedback.ts +1 -1
- package/src/commands/gate.ts +1 -1
- package/src/commands/gov.ts +1 -1
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +10 -2
- package/src/commands/intake.ts +1 -1
- package/src/commands/inventory.ts +9 -1
- package/src/commands/labels.ts +1 -1
- package/src/commands/markdown.ts +1 -1
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pr.ts +141 -2
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +1 -1
- package/src/commands/secrets.ts +9 -1
- package/src/commands/sessions.ts +1 -1
- package/src/commands/slides.ts +1 -1
- package/src/commands/standards.ts +1 -1
- package/src/commands/sync.ts +2 -2
- package/src/commands/targets.ts +1 -1
- package/src/commands/tasks.ts +1 -1
- package/src/commands/teach.ts +1 -1
- package/src/commands/tooling.ts +1 -1
- package/src/commands/transcripts.ts +1 -1
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/wiki.ts +1 -1
- package/src/commands/worktrees.ts +1 -1
- package/src/design/brand-favicon.ts +18 -0
- package/src/gate/measures.ts +1 -1
- package/src/init/run.ts +1 -1
- package/src/migrate/rename.ts +1 -1
- package/src/pr/evidence.ts +41 -7
- package/src/roots/project.ts +1 -1
- package/src/sandbox/headless/run.ts +1 -1
- package/src/sandbox/provision.ts +1 -1
- package/src/sandbox/scenario.ts +1 -1
- package/src/serve/report.ts +1 -1
- package/src/sync/engine.ts +1 -1
- package/src/sync/git.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/validate.ts +1 -1
- package/src/teach/nav.ts +5 -12
- package/src/tooling/inject.ts +1 -1
- package/src/tooling/verify-stack.ts +1 -1
- /package/src/{cli-run.ts → cli/run.ts} +0 -0
- /package/src/{ui.ts → cli/ui.ts} +0 -0
|
@@ -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
|
|
29
|
-
|
|
|
30
|
-
| 1. <the box as written, shortened> |
|
|
31
|
-
| 2. <box>
|
|
32
|
-
| 3. <box> |
|
|
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 `` 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.
|
package/docs/agents/canvas.md
CHANGED
|
@@ -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
|
|
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
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
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,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 {
|
|
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
|
|
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
|
|
244
|
-
* last answered with in its own address,
|
|
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(
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* is
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
|
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)
|
|
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;
|