@erclx/canon 5.14.0 → 5.15.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 (79) hide show
  1. package/README.md +3 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
  4. package/claude/skills/draft-screencast/SKILL.md +14 -9
  5. package/claude/skills/read-frames/SKILL.md +3 -2
  6. package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
  7. package/claude/skills/record-screencast/SKILL.md +28 -5
  8. package/claude/skills/review-pr/SKILL.md +1 -1
  9. package/claude/skills/review-pr/references/close-out.md +1 -1
  10. package/claude/skills/review-pr/references/post.md +1 -1
  11. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  12. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
  13. package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
  14. package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
  15. package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
  16. package/docs/agents/commands.md +3 -0
  17. package/docs/agents/demo.md +34 -7
  18. package/docs/agents/migrate.md +1 -1
  19. package/docs/agents/pr-reads.md +1 -1
  20. package/docs/agents/sandbox.md +28 -3
  21. package/docs/agents/state-scoped-risk.md +2 -2
  22. package/docs/target/migrations.md +1 -1
  23. package/docs/workflow/ai-workflow.md +2 -3
  24. package/docs/workflow/skill-map.md +1 -1
  25. package/governance/renames.toml +7 -5
  26. package/governance/rules/canon/630-requirements.md +1 -1
  27. package/package.json +8 -6
  28. package/scripts/core/check-color-source.sh +11 -1
  29. package/scripts/core/install-check.sh +13 -4
  30. package/scripts/lib/sandbox-dispatch.sh +1 -1
  31. package/scripts/lib/sandbox-fixtures.sh +2 -2
  32. package/scripts/lib/sandbox-path.sh +1 -1
  33. package/scripts/manage-sandbox.sh +2 -2
  34. package/src/canvas/client/app.tsx +85 -0
  35. package/src/canvas/client/main.tsx +62 -0
  36. package/src/canvas/client/pages-panel.tsx +123 -0
  37. package/src/canvas/client/shell.css +319 -0
  38. package/src/canvas/client/state.ts +186 -0
  39. package/src/canvas/client/surface.tsx +213 -0
  40. package/src/canvas/content.ts +326 -0
  41. package/src/canvas/index.html +14 -0
  42. package/src/canvas/server.ts +302 -0
  43. package/src/canvas/tokens.ts +71 -0
  44. package/src/claude/seeds.ts +0 -1
  45. package/src/cli.ts +2 -0
  46. package/src/commands/canvas.ts +325 -0
  47. package/src/commands/claude.ts +9 -14
  48. package/src/commands/demo.ts +6 -6
  49. package/src/commands/sandbox.ts +110 -2
  50. package/src/context/citations.ts +1 -1
  51. package/src/context/classify/prompts.ts +1 -1
  52. package/src/context/folders.ts +0 -1
  53. package/src/demo/compile.ts +8 -4
  54. package/src/demo/container.ts +1 -1
  55. package/src/gov/renames.ts +3 -2
  56. package/src/help.ts +1 -0
  57. package/src/record-root.ts +1 -0
  58. package/src/sandbox/census.ts +1 -1
  59. package/src/sandbox/coverage.ts +1 -1
  60. package/src/sandbox/equivalence.ts +847 -0
  61. package/src/sandbox/expect.ts +0 -1
  62. package/src/serve/static.ts +25 -11
  63. package/src/shipped/references.ts +8 -10
  64. package/src/surface-root.ts +0 -1
  65. package/src/targets/pulls.ts +2 -2
  66. package/standards/architecture.md +1 -1
  67. package/standards/context.md +1 -1
  68. package/standards/design.md +1 -1
  69. package/standards/index.md +0 -1
  70. package/standards/requirements.md +4 -6
  71. package/standards/wireframes.md +2 -2
  72. package/tooling/claude/reference.md +0 -1
  73. package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
  74. package/tsconfig.json +1 -1
  75. package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
  76. package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
  77. package/governance/rules/canon/645-decisions.md +0 -12
  78. package/standards/decisions.md +0 -105
  79. package/tooling/claude/seeds/canon/decisions/index.md +0 -8
@@ -22,7 +22,7 @@ Usage: scripts/core/install-check.sh [--keep]
22
22
 
23
23
  Verifies the published install path end to end:
24
24
  1. Packs this repo into a tarball at .canon/tmp/install-check/pack
25
- 2. Extracts it and asserts scripts/sandbox is absent from the tree
25
+ 2. Extracts it and asserts sandbox is absent from the tree
26
26
  3. Runs bun install --production in the extracted tree
27
27
  4. Runs the CLI with --help from the extracted tree to confirm it resolves
28
28
  5. Scaffolds a fresh project in .canon/tmp/install-check/target
@@ -51,6 +51,15 @@ mkdir -p "$PACK_DIR" "$EXTRACT_DIR"
51
51
 
52
52
  open_timeline "Install verification"
53
53
 
54
+ # An absence check over a path the source tree no longer holds passes on
55
+ # nothing, so the excluded tree has to exist here before its absence below can
56
+ # mean the publish left it out.
57
+ log_step "Assert excluded path exists in source"
58
+ if [ ! -f "$PROJECT_ROOT/sandbox/run.sh" ]; then
59
+ log_error "sandbox/run.sh is missing from the source tree, so the absence check below would pass on nothing"
60
+ fi
61
+ log_info "Present: sandbox/run.sh"
62
+
54
63
  log_step "Pack"
55
64
  TARBALL_PATH="$(cd "$PROJECT_ROOT" && bun pm pack --quiet --ignore-scripts --destination "$PACK_DIR" | tail -n 1)"
56
65
  log_info "Packed to $TARBALL_PATH"
@@ -60,10 +69,10 @@ tar -xzf "$TARBALL_PATH" -C "$EXTRACT_DIR" --strip-components=1
60
69
  log_info "Extracted to $EXTRACT_DIR"
61
70
 
62
71
  log_step "Assert excluded path is absent"
63
- if [ -e "$EXTRACT_DIR/scripts/sandbox" ]; then
64
- log_error "scripts/sandbox shipped in the tarball, and the files field in package.json excludes it"
72
+ if [ -e "$EXTRACT_DIR/sandbox" ]; then
73
+ log_error "sandbox shipped in the tarball, and the files field in package.json leaves it out"
65
74
  fi
66
- log_info "Absent: scripts/sandbox"
75
+ log_info "Absent: sandbox"
67
76
 
68
77
  log_step "Install dependencies (production)"
69
78
  (cd "$EXTRACT_DIR" && bun install --production --ignore-scripts --silent 2>&1 | pipe_output) || log_error "bun install --production failed"
@@ -9,7 +9,7 @@
9
9
  # them, so the escape watch reported clean throughout and was right to. The run
10
10
  # spent real cost until a person found it, and `SIGTERM` alone did not end it.
11
11
  #
12
- # Three mechanisms sit here rather than in `scripts/sandbox/run.sh`, which is the
12
+ # Three mechanisms sit here rather than in `sandbox/run.sh`, which is the
13
13
  # only caller. Each is reachable from a test that never launches a session:
14
14
  # `src/sandbox-dispatch.test.ts` drives the shim against a stub binary and the
15
15
  # reap against a `sleep`, where proving either through `run.sh` would mean
@@ -6,7 +6,7 @@
6
6
  FIXTURE_SUFFIX=".fixture"
7
7
 
8
8
  # The tree mirrors the scenario path, so `category` and `scenario` together match
9
- # `scripts/sandbox/<category>/<scenario>.sh`. Both segments are load-bearing:
9
+ # `sandbox/<category>/<scenario>.sh`. Both segments are load-bearing:
10
10
  # four scenario basenames repeat across categories (`claude`, `docs`, `review`,
11
11
  # `sync`), and `docs` is also a category of its own.
12
12
  fixture_stage_dir() {
@@ -14,7 +14,7 @@ fixture_stage_dir() {
14
14
  local scenario="$2"
15
15
  local arm="$3"
16
16
  local stage="$4"
17
- echo "$PROJECT_ROOT/scripts/sandbox/fixtures/$category/$scenario/$arm/$stage"
17
+ echo "$PROJECT_ROOT/sandbox/fixtures/$category/$scenario/$arm/$stage"
18
18
  }
19
19
 
20
20
  list_fixture_files() {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
 
3
- # The sandbox tree lives outside the toolkit worktree. `scripts/sandbox/run.sh`
3
+ # The sandbox tree lives outside the toolkit worktree. `sandbox/run.sh`
4
4
  # sets cwd to it for `claude -p`, and every `CLAUDE.md` between that cwd and the
5
5
  # filesystem root loads into the session. Under the repository the toolkit's own
6
6
  # instructions join that chain beside the seeded copy the scenario installed,
@@ -40,7 +40,7 @@ show_help() {
40
40
  exit 0
41
41
  }
42
42
 
43
- ANCHOR_FIXTURE_DIR="$PROJECT_ROOT/scripts/sandbox/fixtures/anchor/create"
43
+ ANCHOR_FIXTURE_DIR="$PROJECT_ROOT/sandbox/fixtures/anchor/create"
44
44
 
45
45
  # Where an anchor scenario's tree comes from and where its remote points are two
46
46
  # concerns, and only the remote needs a network. Taking the tree from a fixture
@@ -438,7 +438,7 @@ main() {
438
438
  fi
439
439
 
440
440
  SANDBOX="$(resolve_sandbox_dir)"
441
- SANDBOX_DIR="$PROJECT_ROOT/scripts/sandbox"
441
+ SANDBOX_DIR="$PROJECT_ROOT/sandbox"
442
442
 
443
443
  local unsafe
444
444
  if ! unsafe="$(assert_sandbox_dir_safe "$SANDBOX" "$PROJECT_ROOT")"; then
@@ -0,0 +1,85 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import { useRef } from 'preact/hooks'
4
+ import { PagesPanel } from '@/canvas/client/pages-panel'
5
+ import { currentPage, focusFrame, tokens } from '@/canvas/client/state'
6
+ import { Surface } from '@/canvas/client/surface'
7
+
8
+ const SOURCE_LABEL = {
9
+ toolkit: 'This toolkit',
10
+ installed: 'Installed design',
11
+ none: 'None',
12
+ } as const
13
+
14
+ function TokenDetails(): JSX.Element | null {
15
+ const value = tokens.value
16
+ if (!value) return null
17
+ return (
18
+ <section aria-labelledby="tokens-heading">
19
+ <h2 id="tokens-heading" class="section-label">
20
+ Tokens
21
+ </h2>
22
+ <p class="detail">{SOURCE_LABEL[value.source]}</p>
23
+ {value.files && value.files.length > 0 ? (
24
+ <ul class="files">
25
+ {value.files.map((file) => (
26
+ <li key={file} title={file}>
27
+ <code>{file}</code>
28
+ </li>
29
+ ))}
30
+ </ul>
31
+ ) : null}
32
+ {value.notice ? <p class="notice">{value.notice}</p> : null}
33
+ </section>
34
+ )
35
+ }
36
+
37
+ function PageDetails(): JSX.Element | null {
38
+ const page = currentPage.value
39
+ if (!page) return null
40
+ const unplaced = page.frames.filter((frame) => !frame.placed).length
41
+ return (
42
+ <section aria-labelledby="page-heading">
43
+ <h2 id="page-heading" class="section-label">
44
+ Page
45
+ </h2>
46
+ <p class="detail" title={page.name}>
47
+ {page.name}
48
+ </p>
49
+ <p class="detail-meta">
50
+ {page.frames.length} {page.frames.length === 1 ? 'frame' : 'frames'}
51
+ </p>
52
+ {unplaced > 0 ? (
53
+ <p class="notice">
54
+ {unplaced} {unplaced === 1 ? 'frame has' : 'frames have'} no box in
55
+ layout.json and sit in a default row.
56
+ </p>
57
+ ) : null}
58
+ {page.layoutIssue === 'malformed' ? (
59
+ <p class="notice" role="alert">
60
+ layout.json does not parse, so every frame sits in a default row. Fix
61
+ the file to restore their places.
62
+ </p>
63
+ ) : null}
64
+ </section>
65
+ )
66
+ }
67
+
68
+ export function App(): JSX.Element {
69
+ const viewportRef = useRef<HTMLDivElement>(null)
70
+ return (
71
+ <div class="shell">
72
+ <PagesPanel
73
+ onFocusFrame={(frame) => {
74
+ const rect = viewportRef.current?.getBoundingClientRect()
75
+ focusFrame(frame, rect?.width ?? 0, rect?.height ?? 0)
76
+ }}
77
+ />
78
+ <Surface viewportRef={viewportRef} />
79
+ <aside class="panel panel-right" aria-label="Details">
80
+ <PageDetails />
81
+ <TokenDetails />
82
+ </aside>
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,62 @@
1
+ /** @jsxImportSource preact */
2
+ import { effect } from '@preact/signals'
3
+ import { render } from 'preact'
4
+ import { App } from '@/canvas/client/app'
5
+ import {
6
+ applyChange,
7
+ type ChangeEvent,
8
+ loadPages,
9
+ systemTheme,
10
+ theme,
11
+ themeChoice,
12
+ type Theme,
13
+ } from '@/canvas/client/state'
14
+
15
+ const THEME_KEY = 'canon-canvas-theme'
16
+
17
+ function readStoredTheme(): Theme | undefined {
18
+ try {
19
+ const stored = localStorage.getItem(THEME_KEY)
20
+ return stored === 'light' || stored === 'dark' ? stored : undefined
21
+ } catch {
22
+ return undefined
23
+ }
24
+ }
25
+
26
+ function storeTheme(value: Theme | undefined): void {
27
+ try {
28
+ if (value === undefined) localStorage.removeItem(THEME_KEY)
29
+ else localStorage.setItem(THEME_KEY, value)
30
+ } catch {
31
+ /* A blocked store costs the remembered pick, nothing else. */
32
+ }
33
+ }
34
+
35
+ const darkQuery = matchMedia('(prefers-color-scheme: dark)')
36
+ systemTheme.value = darkQuery.matches ? 'dark' : 'light'
37
+ darkQuery.addEventListener('change', (event) => {
38
+ systemTheme.value = event.matches ? 'dark' : 'light'
39
+ })
40
+ themeChoice.value = readStoredTheme()
41
+
42
+ effect(() => {
43
+ document.documentElement.dataset.theme = theme.value
44
+ document.documentElement.style.colorScheme = theme.value
45
+ })
46
+ effect(() => storeTheme(themeChoice.value))
47
+
48
+ /* The chrome reads this toolkit's own tokens, served beside the shell. */
49
+ const chrome = document.createElement('link')
50
+ chrome.rel = 'stylesheet'
51
+ chrome.href = '/api/chrome.css'
52
+ document.head.prepend(chrome)
53
+
54
+ const events = new EventSource('/api/events')
55
+ events.addEventListener('message', (message) => {
56
+ void applyChange(JSON.parse(message.data) as ChangeEvent)
57
+ })
58
+
59
+ void loadPages()
60
+
61
+ const mount = document.getElementById('app')
62
+ if (mount) render(<App />, mount)
@@ -0,0 +1,123 @@
1
+ /** @jsxImportSource preact */
2
+ import type { JSX } from 'preact'
3
+ import type { Frame } from '@/canvas/content'
4
+ import {
5
+ currentPage,
6
+ isLoaded,
7
+ loadError,
8
+ pages,
9
+ selectedPage,
10
+ theme,
11
+ toggleTheme,
12
+ } from '@/canvas/client/state'
13
+
14
+ export interface PagesPanelProps {
15
+ /** Brings a frame into view on the surface. */
16
+ readonly onFocusFrame: (frame: Frame) => void
17
+ }
18
+
19
+ function ThemeToggle(): JSX.Element {
20
+ const next = theme.value === 'dark' ? 'light' : 'dark'
21
+ return (
22
+ <button
23
+ type="button"
24
+ class="icon-button"
25
+ aria-label={`Switch to ${next} theme`}
26
+ title={`Switch to ${next} theme`}
27
+ onClick={toggleTheme}
28
+ >
29
+ <span aria-hidden="true">{theme.value === 'dark' ? '☾' : '☀'}</span>
30
+ </button>
31
+ )
32
+ }
33
+
34
+ function PageList(): JSX.Element {
35
+ if (pages.value.length === 0) {
36
+ return (
37
+ <p class="empty">
38
+ No pages yet. Run <code>canon canvas page add &lt;name&gt;</code> to add
39
+ one.
40
+ </p>
41
+ )
42
+ }
43
+ const current = currentPage.value?.name
44
+ return (
45
+ <ul class="list" aria-label="Pages">
46
+ {pages.value.map((page) => (
47
+ <li key={page.name}>
48
+ <button
49
+ type="button"
50
+ class="row"
51
+ aria-current={page.name === current ? 'page' : undefined}
52
+ title={page.name}
53
+ onClick={() => {
54
+ selectedPage.value = page.name
55
+ }}
56
+ >
57
+ <span class="row-label">{page.name}</span>
58
+ <span class="row-meta">{page.frames.length}</span>
59
+ </button>
60
+ </li>
61
+ ))}
62
+ </ul>
63
+ )
64
+ }
65
+
66
+ function FrameList({ onFocusFrame }: PagesPanelProps): JSX.Element | null {
67
+ const page = currentPage.value
68
+ if (!page) return null
69
+ if (page.frames.length === 0) {
70
+ return (
71
+ <p class="empty">
72
+ No frames on this page. Run{' '}
73
+ <code>canon canvas frame add {page.name} &lt;name&gt;</code> to add one.
74
+ </p>
75
+ )
76
+ }
77
+ return (
78
+ <ul class="list" aria-label="Frames">
79
+ {page.frames.map((frame) => (
80
+ <li key={frame.name}>
81
+ <button
82
+ type="button"
83
+ class="row"
84
+ title={frame.name}
85
+ onClick={() => onFocusFrame(frame)}
86
+ >
87
+ <span class="row-label">{frame.name}</span>
88
+ <span class="row-meta">{frame.width}</span>
89
+ </button>
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ )
94
+ }
95
+
96
+ export function PagesPanel(props: PagesPanelProps): JSX.Element {
97
+ return (
98
+ <nav class="panel panel-left" aria-label="Pages and frames">
99
+ <header class="panel-head">
100
+ <span class="brand">Canvas</span>
101
+ <ThemeToggle />
102
+ </header>
103
+ {loadError.value ? (
104
+ <p class="empty" role="alert">
105
+ {loadError.value}
106
+ </p>
107
+ ) : !isLoaded.value ? (
108
+ <p class="empty">Loading</p>
109
+ ) : (
110
+ <>
111
+ <h2 class="section-label">Pages</h2>
112
+ <PageList />
113
+ {currentPage.value ? (
114
+ <>
115
+ <h2 class="section-label">Frames</h2>
116
+ <FrameList {...props} />
117
+ </>
118
+ ) : null}
119
+ </>
120
+ )}
121
+ </nav>
122
+ )
123
+ }
@@ -0,0 +1,319 @@
1
+ /*
2
+ * The canvas chrome. Every value reads a custom property from this toolkit's
3
+ * own token module, served at /api/chrome.css, which maps dark onto :root and
4
+ * light onto [data-theme='light']. A frame reads the project's tokens inside
5
+ * its own iframe, so the two sets of names never meet.
6
+ */
7
+
8
+ * {
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ html,
13
+ body {
14
+ height: 100%;
15
+ margin: 0;
16
+ }
17
+
18
+ body {
19
+ background: var(--color-background);
20
+ color: var(--color-text);
21
+ font-family: var(--type-body-family);
22
+ font-size: var(--type-label-size);
23
+ line-height: var(--type-label-lh);
24
+ }
25
+
26
+ #app {
27
+ height: 100%;
28
+ }
29
+
30
+ :focus-visible {
31
+ outline: 2px solid var(--color-accent);
32
+ outline-offset: 2px;
33
+ }
34
+
35
+ code {
36
+ font-family: var(--type-code-family);
37
+ font-size: var(--type-code-size);
38
+ }
39
+
40
+ .shell {
41
+ display: grid;
42
+ grid-template-columns: 15rem minmax(0, 1fr) 13rem;
43
+ height: 100%;
44
+ }
45
+
46
+ .panel {
47
+ overflow-y: auto;
48
+ padding: var(--space-md);
49
+ background: var(--color-chrome);
50
+ min-width: 0;
51
+ }
52
+
53
+ .panel-left {
54
+ border-right: 1px solid var(--color-border);
55
+ }
56
+
57
+ .panel-right {
58
+ border-left: 1px solid var(--color-border);
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: var(--space-lg);
62
+ }
63
+
64
+ .panel-head {
65
+ display: flex;
66
+ align-items: center;
67
+ justify-content: space-between;
68
+ margin-bottom: var(--space-md);
69
+ }
70
+
71
+ .brand {
72
+ font-weight: 600;
73
+ color: var(--color-text);
74
+ }
75
+
76
+ .section-label {
77
+ margin: var(--space-md) 0 var(--space-xs);
78
+ font-size: var(--type-label-size);
79
+ font-weight: 500;
80
+ color: var(--color-text-secondary);
81
+ }
82
+
83
+ .panel-right .section-label {
84
+ margin-top: 0;
85
+ }
86
+
87
+ .list {
88
+ list-style: none;
89
+ margin: 0;
90
+ padding: 2px;
91
+ display: flex;
92
+ flex-direction: column;
93
+ gap: 2px;
94
+ }
95
+
96
+ .row {
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: space-between;
100
+ gap: var(--space-sm);
101
+ width: 100%;
102
+ min-height: 28px;
103
+ padding: var(--space-xs) var(--space-sm);
104
+ border: 0;
105
+ border-radius: var(--radius-action);
106
+ background: transparent;
107
+ color: var(--color-text-body);
108
+ font: inherit;
109
+ text-align: left;
110
+ cursor: pointer;
111
+ }
112
+
113
+ .row:hover {
114
+ background: var(--color-surface);
115
+ color: var(--color-text);
116
+ }
117
+
118
+ .row:active {
119
+ background: var(--color-border);
120
+ }
121
+
122
+ .row[aria-current='page'] {
123
+ background: var(--color-surface);
124
+ color: var(--color-text);
125
+ box-shadow: inset 2px 0 0 var(--color-accent);
126
+ }
127
+
128
+ .row-label {
129
+ overflow: hidden;
130
+ text-overflow: ellipsis;
131
+ white-space: nowrap;
132
+ }
133
+
134
+ .row-meta {
135
+ flex-shrink: 0;
136
+ color: var(--color-muted);
137
+ font-variant-numeric: tabular-nums;
138
+ }
139
+
140
+ .empty,
141
+ .notice {
142
+ margin: var(--space-xs) 0;
143
+ color: var(--color-text-secondary);
144
+ }
145
+
146
+ .notice {
147
+ border-left: 2px solid var(--color-accent);
148
+ padding-left: var(--space-sm);
149
+ }
150
+
151
+ .detail {
152
+ margin: 0;
153
+ color: var(--color-text);
154
+ overflow: hidden;
155
+ text-overflow: ellipsis;
156
+ white-space: nowrap;
157
+ }
158
+
159
+ .detail-meta {
160
+ margin: 0;
161
+ color: var(--color-muted);
162
+ }
163
+
164
+ .files {
165
+ list-style: none;
166
+ margin: var(--space-xs) 0 0;
167
+ padding: 0;
168
+ }
169
+
170
+ .files li {
171
+ overflow: hidden;
172
+ text-overflow: ellipsis;
173
+ white-space: nowrap;
174
+ color: var(--color-text-body);
175
+ }
176
+
177
+ .icon-button,
178
+ .text-button {
179
+ min-width: 28px;
180
+ min-height: 28px;
181
+ padding: 0 var(--space-sm);
182
+ border: 1px solid var(--color-border);
183
+ border-radius: var(--radius-action);
184
+ background: var(--color-surface);
185
+ color: var(--color-text);
186
+ font: inherit;
187
+ cursor: pointer;
188
+ }
189
+
190
+ .icon-button:hover,
191
+ .text-button:hover {
192
+ border-color: var(--color-text-secondary);
193
+ }
194
+
195
+ .icon-button:active,
196
+ .text-button:active {
197
+ background: var(--color-border);
198
+ }
199
+
200
+ .surface {
201
+ position: relative;
202
+ min-width: 0;
203
+ overflow: hidden;
204
+ background: var(--color-background);
205
+ }
206
+
207
+ .viewport {
208
+ position: absolute;
209
+ inset: 0;
210
+ overflow: hidden;
211
+ cursor: grab;
212
+ touch-action: none;
213
+ }
214
+
215
+ .viewport:active {
216
+ cursor: grabbing;
217
+ }
218
+
219
+ .plane {
220
+ position: absolute;
221
+ top: 0;
222
+ left: 0;
223
+ transform-origin: 0 0;
224
+ }
225
+
226
+ .frame {
227
+ position: absolute;
228
+ margin: 0;
229
+ cursor: default;
230
+ }
231
+
232
+ .frame-label {
233
+ position: absolute;
234
+ bottom: 100%;
235
+ left: 0;
236
+ right: 0;
237
+ display: flex;
238
+ align-items: center;
239
+ gap: var(--space-sm);
240
+ padding-bottom: var(--space-sm);
241
+ font-size: 1.5rem;
242
+ color: var(--color-text-secondary);
243
+ white-space: nowrap;
244
+ }
245
+
246
+ .frame-name {
247
+ overflow: hidden;
248
+ text-overflow: ellipsis;
249
+ color: var(--color-text);
250
+ }
251
+
252
+ .frame-size {
253
+ color: var(--color-muted);
254
+ font-variant-numeric: tabular-nums;
255
+ }
256
+
257
+ .frame-theme {
258
+ margin-left: auto;
259
+ padding: 2px var(--space-sm);
260
+ border: 1px solid var(--color-border);
261
+ border-radius: var(--radius-pill);
262
+ background: var(--color-surface);
263
+ color: var(--color-text-secondary);
264
+ font: inherit;
265
+ cursor: pointer;
266
+ }
267
+
268
+ .frame-theme:hover {
269
+ color: var(--color-text);
270
+ }
271
+
272
+ .frame iframe {
273
+ display: block;
274
+ border: 1px solid var(--color-border);
275
+ border-radius: 2px;
276
+ }
277
+
278
+ .toolbar {
279
+ position: absolute;
280
+ right: var(--space-md);
281
+ bottom: var(--space-md);
282
+ display: flex;
283
+ align-items: center;
284
+ gap: var(--space-xs);
285
+ padding: var(--space-xs);
286
+ border: 1px solid var(--color-border);
287
+ border-radius: var(--radius-panel);
288
+ background: var(--color-chrome);
289
+ }
290
+
291
+ .zoom {
292
+ min-width: 3.5rem;
293
+ text-align: center;
294
+ font-variant-numeric: tabular-nums;
295
+ color: var(--color-text-secondary);
296
+ }
297
+
298
+ @media (max-width: 900px) {
299
+ .shell {
300
+ grid-template-columns: 12rem minmax(0, 1fr);
301
+ }
302
+
303
+ .panel-right {
304
+ display: none;
305
+ }
306
+ }
307
+
308
+ @media (max-width: 600px) {
309
+ .shell {
310
+ grid-template-columns: minmax(0, 1fr);
311
+ grid-template-rows: auto minmax(0, 1fr);
312
+ }
313
+
314
+ .panel-left {
315
+ max-height: 40vh;
316
+ border-right: 0;
317
+ border-bottom: 1px solid var(--color-border);
318
+ }
319
+ }