@erclx/canon 5.17.0 → 5.19.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/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
- package/claude/skills/deploy-app/SKILL.md +45 -0
- package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
- package/claude/skills/deploy-app/references/vercel.md +72 -0
- package/docs/agents/architecture-record.md +12 -4
- package/docs/agents/canvas.md +13 -1
- package/docs/agents/commands.md +3 -2
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +4 -4
- package/docs/agents/index.md +2 -2
- package/docs/agents/teach.md +7 -1
- package/docs/workflow/skill-map.md +9 -9
- package/package.json +2 -1
- package/src/audits/catalog.ts +46 -1
- package/src/canvas/address.ts +73 -39
- package/src/canvas/client/inspector.tsx +241 -33
- package/src/canvas/client/pages-panel.tsx +51 -0
- package/src/canvas/client/shell.css +126 -1
- package/src/canvas/client/state.ts +120 -4
- package/src/canvas/client/theme-panel.tsx +55 -0
- package/src/canvas/content.ts +14 -3
- package/src/canvas/edit.ts +360 -0
- package/src/canvas/server.ts +50 -5
- package/src/canvas/tokens.ts +82 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/commands/canvas.ts +95 -1
- package/src/commands/context.ts +85 -5
- package/src/commands/slides.ts +73 -4
- package/src/commands/teach.ts +112 -4
- package/src/context/architecture.ts +98 -18
- package/src/context/gate.ts +30 -3
- package/src/context/requirements.ts +77 -0
- package/src/gate/measures.ts +69 -28
- package/src/gate/stages.ts +5 -5
- package/src/serve/live.ts +113 -0
- package/src/serve/static.ts +60 -2
- package/src/slides/convert/export.ts +323 -0
- package/src/slides/convert/shapes.ts +764 -0
- package/src/slides/convert/svg.ts +38 -0
- package/src/slides/convert/walk.ts +444 -0
- package/src/teach/browser/reload.ts +9 -0
- package/src/teach/nav.ts +7 -6
- package/src/teach/workspace.ts +2 -1
- package/src/teach/write-if-changed.ts +17 -0
- package/standards/architecture.md +13 -7
- package/standards/requirements.md +4 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
- package/tooling/cloudflare/reference.md +1 -1
- package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
package/src/canvas/address.ts
CHANGED
|
@@ -90,6 +90,58 @@ interface Collected {
|
|
|
90
90
|
text: string
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Called on each element the address counts, with its index. What it returns
|
|
95
|
+
* runs at the element's end tag, since a second `onEndTag` on one element
|
|
96
|
+
* replaces the first rather than adding to it.
|
|
97
|
+
*/
|
|
98
|
+
export type ElementVisitor = (
|
|
99
|
+
element: HTMLRewriterTypes.Element,
|
|
100
|
+
index: number,
|
|
101
|
+
) => (() => void) | undefined
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Registers the one counting rule both the reader and the edit writer follow,
|
|
105
|
+
* so an index means the same element to each.
|
|
106
|
+
*/
|
|
107
|
+
export function countElements(
|
|
108
|
+
rewriter: HTMLRewriter,
|
|
109
|
+
visit: ElementVisitor,
|
|
110
|
+
): HTMLRewriter {
|
|
111
|
+
let count = 0
|
|
112
|
+
/* A template's content is a fragment `querySelectorAll` never reaches. */
|
|
113
|
+
let templateDepth = 0
|
|
114
|
+
|
|
115
|
+
return rewriter.on('*', {
|
|
116
|
+
element(element) {
|
|
117
|
+
const tag = element.tagName.toLowerCase()
|
|
118
|
+
if (templateDepth > 0) {
|
|
119
|
+
if (tag === 'template') {
|
|
120
|
+
templateDepth += 1
|
|
121
|
+
element.onEndTag(() => {
|
|
122
|
+
templateDepth -= 1
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
const end = visit(element, count)
|
|
128
|
+
count += 1
|
|
129
|
+
if (!element.canHaveContent || element.selfClosing) return
|
|
130
|
+
const isTemplate = tag === 'template'
|
|
131
|
+
if (isTemplate) templateDepth += 1
|
|
132
|
+
element.onEndTag(() => {
|
|
133
|
+
if (isTemplate) templateDepth -= 1
|
|
134
|
+
end?.()
|
|
135
|
+
})
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Whether an element's content is raw text rather than parsed markup. */
|
|
141
|
+
export function isRawText(tag: string): boolean {
|
|
142
|
+
return RAW_TEXT.has(tag)
|
|
143
|
+
}
|
|
144
|
+
|
|
93
145
|
/**
|
|
94
146
|
* Every element the file states, in document order. Text is gathered into
|
|
95
147
|
* each open element, which an element closed only by implication keeps open to
|
|
@@ -99,8 +151,6 @@ export function sourceElements(html: string): SourceElement[] {
|
|
|
99
151
|
const elements: Collected[] = []
|
|
100
152
|
const open: number[] = []
|
|
101
153
|
let rawDepth = 0
|
|
102
|
-
/* A template's content is a fragment `querySelectorAll` never reaches. */
|
|
103
|
-
let templateDepth = 0
|
|
104
154
|
|
|
105
155
|
const appendText = (text: string) => {
|
|
106
156
|
for (const index of open) {
|
|
@@ -111,44 +161,28 @@ export function sourceElements(html: string): SourceElement[] {
|
|
|
111
161
|
}
|
|
112
162
|
}
|
|
113
163
|
|
|
114
|
-
new HTMLRewriter()
|
|
115
|
-
.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
return
|
|
126
|
-
}
|
|
127
|
-
const index = elements.length
|
|
128
|
-
/* A tag boundary separates words the way a rendered block would. */
|
|
129
|
-
appendText(' ')
|
|
130
|
-
elements.push({
|
|
131
|
-
tag,
|
|
132
|
-
classes: (element.getAttribute('class') ?? '')
|
|
133
|
-
.split(/\s+/)
|
|
134
|
-
.filter(Boolean),
|
|
135
|
-
text: '',
|
|
136
|
-
})
|
|
137
|
-
if (!element.canHaveContent || element.selfClosing) return
|
|
138
|
-
const isRaw = RAW_TEXT.has(tag)
|
|
139
|
-
const isTemplate = tag === 'template'
|
|
140
|
-
open.push(index)
|
|
141
|
-
if (isRaw) rawDepth += 1
|
|
142
|
-
if (isTemplate) templateDepth += 1
|
|
143
|
-
element.onEndTag(() => {
|
|
144
|
-
const at = open.lastIndexOf(index)
|
|
145
|
-
if (at !== -1) open.splice(at, 1)
|
|
146
|
-
if (isRaw) rawDepth -= 1
|
|
147
|
-
if (isTemplate) templateDepth -= 1
|
|
148
|
-
appendText(' ')
|
|
149
|
-
})
|
|
150
|
-
},
|
|
164
|
+
countElements(new HTMLRewriter(), (element, index) => {
|
|
165
|
+
const tag = element.tagName.toLowerCase()
|
|
166
|
+
/* A tag boundary separates words the way a rendered block would. */
|
|
167
|
+
appendText(' ')
|
|
168
|
+
elements.push({
|
|
169
|
+
tag,
|
|
170
|
+
classes: (element.getAttribute('class') ?? '')
|
|
171
|
+
.split(/\s+/)
|
|
172
|
+
.filter(Boolean),
|
|
173
|
+
text: '',
|
|
151
174
|
})
|
|
175
|
+
if (!element.canHaveContent || element.selfClosing) return undefined
|
|
176
|
+
const isRaw = isRawText(tag)
|
|
177
|
+
open.push(index)
|
|
178
|
+
if (isRaw) rawDepth += 1
|
|
179
|
+
return () => {
|
|
180
|
+
const at = open.lastIndexOf(index)
|
|
181
|
+
if (at !== -1) open.splice(at, 1)
|
|
182
|
+
if (isRaw) rawDepth -= 1
|
|
183
|
+
appendText(' ')
|
|
184
|
+
}
|
|
185
|
+
})
|
|
152
186
|
.onDocument({
|
|
153
187
|
text(chunk) {
|
|
154
188
|
if (rawDepth === 0) appendText(chunk.text)
|
|
@@ -1,82 +1,286 @@
|
|
|
1
1
|
/** @jsxImportSource preact */
|
|
2
2
|
import type { JSX } from 'preact'
|
|
3
|
-
import { elementAt } from '@/canvas/address'
|
|
3
|
+
import { addressOf, elementAt, excerpt, isRawText } from '@/canvas/address'
|
|
4
4
|
import {
|
|
5
5
|
currentPage,
|
|
6
|
+
editElement,
|
|
7
|
+
editRefusal,
|
|
8
|
+
type FrameRef,
|
|
6
9
|
frameDocuments,
|
|
7
10
|
frameKey,
|
|
11
|
+
frameVersions,
|
|
12
|
+
pendingEdit,
|
|
13
|
+
savedEdit,
|
|
8
14
|
selectedElement,
|
|
9
15
|
selectedFrame,
|
|
16
|
+
tokens,
|
|
10
17
|
writeError,
|
|
11
18
|
} from '@/canvas/client/state'
|
|
12
19
|
|
|
13
20
|
const FIELDS = ['x', 'y', 'width', 'height'] as const
|
|
14
21
|
|
|
22
|
+
/** Elements the parser never gives content, so they hold no text to edit. */
|
|
23
|
+
const VOID = new Set([
|
|
24
|
+
'area',
|
|
25
|
+
'base',
|
|
26
|
+
'br',
|
|
27
|
+
'col',
|
|
28
|
+
'embed',
|
|
29
|
+
'hr',
|
|
30
|
+
'img',
|
|
31
|
+
'input',
|
|
32
|
+
'link',
|
|
33
|
+
'meta',
|
|
34
|
+
'source',
|
|
35
|
+
'track',
|
|
36
|
+
'wbr',
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
interface StyleField {
|
|
40
|
+
readonly label: string
|
|
41
|
+
readonly property: string
|
|
42
|
+
readonly isColor?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The basic set, in the order the panel lists it. The writer refuses any
|
|
47
|
+
* property outside it, so this list and the writer's have to agree.
|
|
48
|
+
*/
|
|
49
|
+
const STYLE_FIELDS: readonly StyleField[] = [
|
|
50
|
+
{ label: 'color', property: 'color', isColor: true },
|
|
51
|
+
{ label: 'background', property: 'background-color', isColor: true },
|
|
52
|
+
{ label: 'size', property: 'font-size' },
|
|
53
|
+
{ label: 'weight', property: 'font-weight' },
|
|
54
|
+
{ label: 'width', property: 'width' },
|
|
55
|
+
{ label: 'height', property: 'height' },
|
|
56
|
+
{ label: 'padding', property: 'padding' },
|
|
57
|
+
{ label: 'gap', property: 'gap' },
|
|
58
|
+
{ label: 'direction', property: 'flex-direction' },
|
|
59
|
+
]
|
|
60
|
+
|
|
15
61
|
interface Row {
|
|
16
62
|
readonly name: string
|
|
17
63
|
readonly value: string
|
|
18
64
|
}
|
|
19
65
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
* rather than what any one stylesheet states.
|
|
23
|
-
*/
|
|
24
|
-
function styleRows(element: Element): Row[] {
|
|
66
|
+
/** Where the element sits in its frame, which the panel shows and never edits. */
|
|
67
|
+
function positionRows(element: Element): Row[] {
|
|
25
68
|
const rect = element.getBoundingClientRect()
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
69
|
+
const font = element.ownerDocument.defaultView
|
|
70
|
+
?.getComputedStyle(element)
|
|
71
|
+
.getPropertyValue('font-family')
|
|
72
|
+
return [
|
|
29
73
|
{ name: 'x', value: String(Math.round(rect.left)) },
|
|
30
74
|
{ name: 'y', value: String(Math.round(rect.top)) },
|
|
31
|
-
{ name: '
|
|
32
|
-
{ name: 'height', value: String(Math.round(rect.height)) },
|
|
33
|
-
]
|
|
34
|
-
if (!style) return rows
|
|
35
|
-
return [
|
|
36
|
-
...rows,
|
|
37
|
-
{ name: 'color', value: style.color },
|
|
38
|
-
{ name: 'background', value: style.backgroundColor },
|
|
39
|
-
{ name: 'font', value: style.fontFamily },
|
|
40
|
-
{ name: 'size', value: style.fontSize },
|
|
41
|
-
{ name: 'weight', value: style.fontWeight },
|
|
75
|
+
...(font ? [{ name: 'font', value: font }] : []),
|
|
42
76
|
]
|
|
43
77
|
}
|
|
44
78
|
|
|
79
|
+
/**
|
|
80
|
+
* What the element states inline, else the value the browser settled on, so
|
|
81
|
+
* an edit starts from what the operator sees.
|
|
82
|
+
*/
|
|
83
|
+
function currentValue(element: Element, property: string): string {
|
|
84
|
+
const inline =
|
|
85
|
+
'style' in element
|
|
86
|
+
? (element as HTMLElement).style.getPropertyValue(property)
|
|
87
|
+
: ''
|
|
88
|
+
if (inline) return inline
|
|
89
|
+
return (
|
|
90
|
+
element.ownerDocument.defaultView
|
|
91
|
+
?.getComputedStyle(element)
|
|
92
|
+
.getPropertyValue(property) ?? ''
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function holdsTextAlone(element: Element): boolean {
|
|
97
|
+
const tag = element.tagName.toLowerCase()
|
|
98
|
+
return element.children.length === 0 && !VOID.has(tag) && !isRawText(tag)
|
|
99
|
+
}
|
|
100
|
+
|
|
45
101
|
function elementName(element: Element): string {
|
|
46
102
|
const tag = element.tagName.toLowerCase()
|
|
47
103
|
return [tag, ...element.classList].join('.')
|
|
48
104
|
}
|
|
49
105
|
|
|
50
|
-
|
|
106
|
+
interface FieldProps {
|
|
107
|
+
readonly label: string
|
|
108
|
+
readonly initial: string
|
|
109
|
+
readonly isBusy: boolean
|
|
110
|
+
readonly onCommit: (value: string) => void
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Commits on change, which a text input fires on Enter or on leaving it, and
|
|
115
|
+
* sends nothing when the value is what it started at. Escape puts it back.
|
|
116
|
+
*/
|
|
117
|
+
function Field({ label, initial, isBusy, onCommit }: FieldProps): JSX.Element {
|
|
118
|
+
return (
|
|
119
|
+
<input
|
|
120
|
+
class="field"
|
|
121
|
+
type="text"
|
|
122
|
+
aria-label={label}
|
|
123
|
+
title={initial}
|
|
124
|
+
defaultValue={initial}
|
|
125
|
+
disabled={isBusy}
|
|
126
|
+
spellcheck={false}
|
|
127
|
+
onChange={(event) => {
|
|
128
|
+
const value = event.currentTarget.value.trim()
|
|
129
|
+
if (value !== initial.trim()) onCommit(value)
|
|
130
|
+
}}
|
|
131
|
+
onKeyDown={(event) => {
|
|
132
|
+
if (event.key === 'Escape') event.currentTarget.value = initial
|
|
133
|
+
}}
|
|
134
|
+
/>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function TokenPicker({
|
|
139
|
+
label,
|
|
140
|
+
initial,
|
|
141
|
+
isBusy,
|
|
142
|
+
onCommit,
|
|
143
|
+
}: FieldProps): JSX.Element | null {
|
|
144
|
+
const colors =
|
|
145
|
+
tokens.value?.groups?.find((group) => group.kind === 'color')?.tokens ?? []
|
|
146
|
+
if (colors.length === 0) return null
|
|
147
|
+
return (
|
|
148
|
+
<select
|
|
149
|
+
class="token-picker"
|
|
150
|
+
aria-label={`${label} token`}
|
|
151
|
+
title="Pick a project token"
|
|
152
|
+
value={
|
|
153
|
+
colors.some((token) => initial === `var(${token.name})`) ? initial : ''
|
|
154
|
+
}
|
|
155
|
+
disabled={isBusy}
|
|
156
|
+
onChange={(event) => {
|
|
157
|
+
const { value } = event.currentTarget
|
|
158
|
+
if (value !== '' && value !== initial) onCommit(value)
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<option value="">Token</option>
|
|
162
|
+
{colors.map((token) => (
|
|
163
|
+
<option key={token.name} value={`var(${token.name})`}>
|
|
164
|
+
{token.name}
|
|
165
|
+
</option>
|
|
166
|
+
))}
|
|
167
|
+
</select>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function ElementFields({
|
|
172
|
+
node,
|
|
173
|
+
doc,
|
|
174
|
+
frameRef,
|
|
175
|
+
frameKey: key,
|
|
176
|
+
}: {
|
|
177
|
+
readonly node: Element
|
|
178
|
+
readonly doc: Document
|
|
179
|
+
readonly frameRef: FrameRef
|
|
180
|
+
readonly frameKey: string
|
|
181
|
+
}): JSX.Element {
|
|
182
|
+
const address = addressOf(doc, node)
|
|
183
|
+
const pending = pendingEdit.value
|
|
184
|
+
const isBusy = pending?.key === key && pending.index === address?.index
|
|
185
|
+
const commit = (property: string) => (value: string) => {
|
|
186
|
+
if (address) void editElement(frameRef, key, address, property, value)
|
|
187
|
+
}
|
|
188
|
+
const isTextOnly = holdsTextAlone(node)
|
|
189
|
+
const text = node.textContent ?? ''
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<dl class="box styles">
|
|
193
|
+
{positionRows(node).map((row) => (
|
|
194
|
+
<div key={row.name} class="box-row">
|
|
195
|
+
<dt>{row.name}</dt>
|
|
196
|
+
<dd title={row.value}>{row.value}</dd>
|
|
197
|
+
</div>
|
|
198
|
+
))}
|
|
199
|
+
<div class="box-row">
|
|
200
|
+
<dt>text</dt>
|
|
201
|
+
<dd title={excerpt(text)}>
|
|
202
|
+
{isTextOnly ? (
|
|
203
|
+
<Field
|
|
204
|
+
label="text"
|
|
205
|
+
initial={text}
|
|
206
|
+
isBusy={isBusy}
|
|
207
|
+
onCommit={commit('text')}
|
|
208
|
+
/>
|
|
209
|
+
) : (
|
|
210
|
+
excerpt(text)
|
|
211
|
+
)}
|
|
212
|
+
</dd>
|
|
213
|
+
</div>
|
|
214
|
+
{STYLE_FIELDS.map((field) => {
|
|
215
|
+
const initial = currentValue(node, field.property)
|
|
216
|
+
return (
|
|
217
|
+
<div key={field.property} class="box-row">
|
|
218
|
+
<dt>{field.label}</dt>
|
|
219
|
+
<dd class="field-cell">
|
|
220
|
+
<Field
|
|
221
|
+
label={field.label}
|
|
222
|
+
initial={initial}
|
|
223
|
+
isBusy={isBusy}
|
|
224
|
+
onCommit={commit(field.property)}
|
|
225
|
+
/>
|
|
226
|
+
{field.isColor ? (
|
|
227
|
+
<TokenPicker
|
|
228
|
+
label={field.label}
|
|
229
|
+
initial={initial}
|
|
230
|
+
isBusy={isBusy}
|
|
231
|
+
onCommit={commit(field.property)}
|
|
232
|
+
/>
|
|
233
|
+
) : null}
|
|
234
|
+
</dd>
|
|
235
|
+
</div>
|
|
236
|
+
)
|
|
237
|
+
})}
|
|
238
|
+
</dl>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
51
242
|
function ElementDetails(): JSX.Element | null {
|
|
52
243
|
const picked = selectedElement.value
|
|
53
244
|
const page = currentPage.value
|
|
54
245
|
if (!picked || !page) return null
|
|
55
246
|
const { frame, element } = picked
|
|
56
|
-
const
|
|
247
|
+
const key = frameKey(page.name, frame)
|
|
248
|
+
const doc = frameDocuments.value.get(key)
|
|
57
249
|
const node = doc && !element.stale ? elementAt(doc, element) : undefined
|
|
250
|
+
const saved = savedEdit.value
|
|
251
|
+
const isSaved = saved?.key === key && saved.index === element.index
|
|
58
252
|
|
|
59
253
|
return (
|
|
60
254
|
<section aria-label="Element">
|
|
61
|
-
<h2 class="section-label">
|
|
255
|
+
<h2 class="section-label">
|
|
256
|
+
Element
|
|
257
|
+
{isSaved ? (
|
|
258
|
+
<span class="saved" role="status">
|
|
259
|
+
Saved
|
|
260
|
+
</span>
|
|
261
|
+
) : null}
|
|
262
|
+
</h2>
|
|
62
263
|
{element.stale ? (
|
|
63
264
|
<p class="notice">
|
|
64
265
|
The frame changed since this element was picked, so its index may name
|
|
65
266
|
another element now. Pick it again.
|
|
66
267
|
</p>
|
|
67
|
-
) : node ? (
|
|
268
|
+
) : doc && node ? (
|
|
68
269
|
<>
|
|
69
270
|
<p class="detail" title={elementName(node)}>
|
|
70
271
|
{elementName(node)}
|
|
71
272
|
</p>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
273
|
+
{/*
|
|
274
|
+
* Keyed by the frame's version and the element, so a reload drops
|
|
275
|
+
* any value typed against the document it replaced.
|
|
276
|
+
*/}
|
|
277
|
+
<ElementFields
|
|
278
|
+
key={`${frameVersions.value.get(key) ?? 0}:${element.index}`}
|
|
279
|
+
node={node}
|
|
280
|
+
doc={doc}
|
|
281
|
+
frameRef={{ page: page.name, frame: frame.name }}
|
|
282
|
+
frameKey={key}
|
|
283
|
+
/>
|
|
80
284
|
</>
|
|
81
285
|
) : (
|
|
82
286
|
<p class="empty">Loading the frame to read this element</p>
|
|
@@ -85,7 +289,6 @@ function ElementDetails(): JSX.Element | null {
|
|
|
85
289
|
)
|
|
86
290
|
}
|
|
87
291
|
|
|
88
|
-
/** Read-only in this slice, since editing a box is the edit slice's work. */
|
|
89
292
|
export function Inspector(): JSX.Element {
|
|
90
293
|
const frame = selectedFrame.value
|
|
91
294
|
return (
|
|
@@ -116,6 +319,11 @@ export function Inspector(): JSX.Element {
|
|
|
116
319
|
) : null}
|
|
117
320
|
</section>
|
|
118
321
|
<ElementDetails />
|
|
322
|
+
{editRefusal.value ? (
|
|
323
|
+
<p class="notice" role="alert">
|
|
324
|
+
{editRefusal.value}
|
|
325
|
+
</p>
|
|
326
|
+
) : null}
|
|
119
327
|
</>
|
|
120
328
|
)
|
|
121
329
|
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/** @jsxImportSource preact */
|
|
2
2
|
import type { JSX } from 'preact'
|
|
3
3
|
import { Layers } from '@/canvas/client/layers'
|
|
4
|
+
import { ThemePanel } from '@/canvas/client/theme-panel'
|
|
4
5
|
import type { Frame } from '@/canvas/content'
|
|
5
6
|
import {
|
|
6
7
|
currentPage,
|
|
7
8
|
expandedFrames,
|
|
8
9
|
frameKey,
|
|
9
10
|
isLoaded,
|
|
11
|
+
type LeftTab,
|
|
12
|
+
leftTab,
|
|
10
13
|
loadError,
|
|
11
14
|
pages,
|
|
12
15
|
selectedFrame,
|
|
@@ -127,6 +130,48 @@ function FrameList({ onFocusFrame }: PagesPanelProps): JSX.Element | null {
|
|
|
127
130
|
)
|
|
128
131
|
}
|
|
129
132
|
|
|
133
|
+
const TABS: readonly { readonly id: LeftTab; readonly label: string }[] = [
|
|
134
|
+
{ id: 'pages', label: 'Pages' },
|
|
135
|
+
{ id: 'theme', label: 'Theme' },
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
/** Two tabs, so the arrow keys move between them and wrap. */
|
|
139
|
+
function Tabs(): JSX.Element {
|
|
140
|
+
const select = (at: number) => {
|
|
141
|
+
const tab = TABS[(at + TABS.length) % TABS.length]
|
|
142
|
+
if (!tab) return
|
|
143
|
+
leftTab.value = tab.id
|
|
144
|
+
document.getElementById(`tab-${tab.id}`)?.focus()
|
|
145
|
+
}
|
|
146
|
+
return (
|
|
147
|
+
<div class="tabs" role="tablist" aria-label="Left panel">
|
|
148
|
+
{TABS.map((tab, at) => {
|
|
149
|
+
const isSelected = leftTab.value === tab.id
|
|
150
|
+
return (
|
|
151
|
+
<button
|
|
152
|
+
key={tab.id}
|
|
153
|
+
id={`tab-${tab.id}`}
|
|
154
|
+
type="button"
|
|
155
|
+
role="tab"
|
|
156
|
+
class="tab"
|
|
157
|
+
aria-selected={isSelected}
|
|
158
|
+
tabIndex={isSelected ? 0 : -1}
|
|
159
|
+
onClick={() => {
|
|
160
|
+
leftTab.value = tab.id
|
|
161
|
+
}}
|
|
162
|
+
onKeyDown={(event) => {
|
|
163
|
+
if (event.key === 'ArrowRight') select(at + 1)
|
|
164
|
+
if (event.key === 'ArrowLeft') select(at - 1)
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
{tab.label}
|
|
168
|
+
</button>
|
|
169
|
+
)
|
|
170
|
+
})}
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
130
175
|
export function PagesPanel(props: PagesPanelProps): JSX.Element {
|
|
131
176
|
return (
|
|
132
177
|
<nav class="panel panel-left" aria-label="Pages and frames">
|
|
@@ -140,8 +185,14 @@ export function PagesPanel(props: PagesPanelProps): JSX.Element {
|
|
|
140
185
|
</p>
|
|
141
186
|
) : !isLoaded.value ? (
|
|
142
187
|
<p class="empty">Loading</p>
|
|
188
|
+
) : leftTab.value === 'theme' ? (
|
|
189
|
+
<>
|
|
190
|
+
<Tabs />
|
|
191
|
+
<ThemePanel />
|
|
192
|
+
</>
|
|
143
193
|
) : (
|
|
144
194
|
<>
|
|
195
|
+
<Tabs />
|
|
145
196
|
<h2 class="section-label">Pages</h2>
|
|
146
197
|
<PageList />
|
|
147
198
|
{currentPage.value ? (
|
|
@@ -39,7 +39,7 @@ code {
|
|
|
39
39
|
|
|
40
40
|
.shell {
|
|
41
41
|
display: grid;
|
|
42
|
-
grid-template-columns: 15rem minmax(0, 1fr)
|
|
42
|
+
grid-template-columns: 15rem minmax(0, 1fr) 16rem;
|
|
43
43
|
height: 100%;
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -447,3 +447,128 @@ code {
|
|
|
447
447
|
text-overflow: ellipsis;
|
|
448
448
|
white-space: nowrap;
|
|
449
449
|
}
|
|
450
|
+
|
|
451
|
+
.styles .box-row {
|
|
452
|
+
align-items: center;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.styles dt {
|
|
456
|
+
flex: none;
|
|
457
|
+
width: 4.5rem;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.styles dd {
|
|
461
|
+
flex: 1;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.field-cell {
|
|
465
|
+
display: flex;
|
|
466
|
+
gap: var(--space-xs);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
.field,
|
|
470
|
+
.token-picker {
|
|
471
|
+
min-width: 0;
|
|
472
|
+
min-height: 24px;
|
|
473
|
+
padding: 2px var(--space-xs);
|
|
474
|
+
border: 1px solid var(--color-border);
|
|
475
|
+
border-radius: var(--radius-action);
|
|
476
|
+
background: var(--color-surface);
|
|
477
|
+
color: var(--color-text);
|
|
478
|
+
font: inherit;
|
|
479
|
+
font-variant-numeric: tabular-nums;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.field {
|
|
483
|
+
flex: 1;
|
|
484
|
+
width: 100%;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.token-picker {
|
|
488
|
+
flex: none;
|
|
489
|
+
width: 4.5rem;
|
|
490
|
+
cursor: pointer;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.field:hover,
|
|
494
|
+
.token-picker:hover {
|
|
495
|
+
border-color: var(--color-muted);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.field:disabled,
|
|
499
|
+
.token-picker:disabled {
|
|
500
|
+
opacity: 0.5;
|
|
501
|
+
cursor: not-allowed;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.saved {
|
|
505
|
+
margin-left: var(--space-sm);
|
|
506
|
+
color: var(--color-success);
|
|
507
|
+
font-weight: 400;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.tabs {
|
|
511
|
+
display: flex;
|
|
512
|
+
gap: 2px;
|
|
513
|
+
margin-top: var(--space-sm);
|
|
514
|
+
padding: 2px;
|
|
515
|
+
border-radius: var(--radius-action);
|
|
516
|
+
background: var(--color-surface);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.tab {
|
|
520
|
+
flex: 1;
|
|
521
|
+
min-height: 24px;
|
|
522
|
+
border: 0;
|
|
523
|
+
border-radius: var(--radius-action);
|
|
524
|
+
background: transparent;
|
|
525
|
+
color: var(--color-text-secondary);
|
|
526
|
+
font: inherit;
|
|
527
|
+
cursor: pointer;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.tab:hover {
|
|
531
|
+
color: var(--color-text);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.tab[aria-selected='true'] {
|
|
535
|
+
background: var(--color-chrome);
|
|
536
|
+
color: var(--color-text);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.tokens {
|
|
540
|
+
list-style: none;
|
|
541
|
+
margin: 0;
|
|
542
|
+
padding: 0;
|
|
543
|
+
display: flex;
|
|
544
|
+
flex-direction: column;
|
|
545
|
+
gap: 2px;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
.token {
|
|
549
|
+
display: flex;
|
|
550
|
+
align-items: center;
|
|
551
|
+
gap: var(--space-sm);
|
|
552
|
+
min-height: 24px;
|
|
553
|
+
padding: 0 var(--space-sm);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.token .row-label {
|
|
557
|
+
flex: 1;
|
|
558
|
+
min-width: 0;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.token-value {
|
|
562
|
+
max-width: 45%;
|
|
563
|
+
overflow: hidden;
|
|
564
|
+
text-overflow: ellipsis;
|
|
565
|
+
white-space: nowrap;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.swatch {
|
|
569
|
+
flex: none;
|
|
570
|
+
width: 14px;
|
|
571
|
+
height: 14px;
|
|
572
|
+
border: 1px solid var(--color-border);
|
|
573
|
+
border-radius: 4px;
|
|
574
|
+
}
|