@erclx/canon 5.27.0 → 5.29.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/draft-ready/SKILL.md +3 -1
- package/claude/skills/git-pr/references/evidence.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +4 -2
- package/claude/skills/review-address/SKILL.md +16 -7
- package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
- package/claude/skills/review-craft/references/ui.md +4 -4
- package/claude/skills/review-ui/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +13 -11
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +19 -7
- package/claude/skills/review-ui/references/tick.md +22 -0
- package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
- package/claude/skills/role-orchestrator/SKILL.md +24 -26
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +12 -2
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
- package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +3 -2
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/docs/agents/canvas.md +12 -4
- package/docs/agents/gate.md +3 -1
- package/docs/agents/index.md +2 -1
- package/docs/agents/pr-evidence.md +15 -14
- package/docs/agents/pr-tick.md +72 -0
- package/docs/agents/sandbox.md +2 -2
- package/docs/agents/tasks-plans.md +1 -1
- package/docs/agents/tasks-validate.md +18 -18
- package/docs/agents/tasks.md +1 -1
- package/docs/workflow/operating-model.md +10 -15
- package/docs/workflow/parallel-features.md +7 -7
- package/package.json +1 -1
- package/src/audits/baseline.ts +1 -1
- package/src/audits/run.ts +1 -1
- package/src/canvas/capture.ts +1 -1
- package/src/canvas/client/app.tsx +105 -4
- package/src/canvas/client/inspector/field.tsx +61 -2
- package/src/canvas/client/inspector/fields.css +78 -0
- package/src/canvas/client/inspector/token-select.tsx +109 -0
- package/src/canvas/client/inspector/values.ts +74 -2
- package/src/canvas/client/inspector.tsx +134 -15
- package/src/canvas/client/keys.ts +71 -0
- package/src/canvas/client/pages-panel.tsx +5 -1
- package/src/canvas/client/panel-handle.tsx +90 -0
- package/src/canvas/client/selection.tsx +3 -1
- package/src/canvas/client/shell.css +194 -4
- package/src/canvas/client/state.ts +255 -11
- package/src/canvas/client/surface.tsx +231 -18
- package/src/canvas/client/tools.tsx +153 -0
- package/src/canvas/content.ts +66 -1
- package/src/canvas/edit.ts +207 -25
- package/src/canvas/history.ts +116 -0
- package/src/canvas/server.ts +119 -13
- package/src/canvas/tokens.ts +1 -1
- package/src/claude/seeds.ts +2 -2
- package/src/claude/skills-reach.ts +1 -1
- package/src/{exec.ts → cli/exec.ts} +2 -2
- 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 +2 -2
- package/src/commands/capture.ts +1 -1
- package/src/commands/census.ts +1 -1
- package/src/commands/claude.ts +5 -5
- 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 +12 -4
- package/src/commands/docs.ts +2 -2
- package/src/commands/driver.ts +9 -1
- package/src/commands/feedback.ts +3 -3
- package/src/commands/gate.ts +10 -3
- package/src/commands/gov.ts +3 -3
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +11 -3
- 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 +2 -2
- package/src/commands/pr.ts +324 -9
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +11 -14
- package/src/commands/secrets.ts +9 -1
- package/src/commands/sessions.ts +1 -1
- package/src/commands/slides.ts +2 -2
- package/src/commands/standards.ts +1 -1
- package/src/commands/sync.ts +3 -3
- package/src/commands/targets.ts +1 -1
- package/src/commands/tasks.ts +20 -3
- package/src/commands/teach.ts +1 -1
- package/src/commands/tooling.ts +2 -2
- package/src/commands/transcripts.ts +2 -2
- package/src/commands/upgrade.ts +2 -2
- package/src/commands/wiki.ts +2 -2
- package/src/commands/worktrees.ts +1 -1
- package/src/context/architecture.ts +1 -1
- package/src/context/citations.ts +2 -2
- package/src/context/classify/extract.ts +1 -1
- package/src/context/folders.ts +2 -2
- package/src/context/requirements.ts +1 -1
- package/src/docs/read.ts +1 -1
- package/src/feedback/github.ts +1 -1
- package/src/gate/measures.ts +2 -2
- package/src/gate/sequencer.ts +55 -3
- package/src/gate/stages.ts +8 -0
- package/src/gate/suite-lock.ts +214 -0
- package/src/init/run.ts +1 -1
- package/src/intake/folder.ts +1 -1
- package/src/labels/map.ts +1 -1
- package/src/labels/phase.ts +3 -3
- package/src/migrate/record-layout.ts +1 -1
- package/src/migrate/records.ts +11 -11
- package/src/migrate/rename.ts +1 -1
- package/src/migrate/scratch-evidence.ts +1 -1
- package/src/migrate/surface-roots.ts +6 -6
- package/src/pr/evidence.ts +188 -16
- package/src/pr/tick.ts +56 -0
- package/src/process/harness.ts +1 -1
- package/src/records/backup.ts +1 -1
- package/src/records/ordinal.ts +1 -1
- package/src/records/prune.ts +1 -1
- package/src/records/size.ts +1 -1
- package/src/records/stale-canonical.ts +1 -1
- package/src/records/stale.ts +1 -1
- package/src/records/validate.ts +2 -2
- package/src/{project-root.ts → roots/project.ts} +2 -2
- package/src/{record-root.ts → roots/record.ts} +1 -1
- package/src/sandbox/coverage.ts +11 -12
- package/src/sandbox/equivalence.ts +57 -26
- package/src/sandbox/headless/run.ts +1 -1
- package/src/sandbox/hooks.ts +1 -1
- package/src/sandbox/provision.ts +79 -29
- package/src/sandbox/scenario.ts +588 -0
- package/src/serve/report.ts +1 -1
- package/src/sessions/live.ts +4 -1
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/slides/decks.ts +1 -1
- package/src/standards/read.ts +1 -1
- package/src/sync/engine.ts +2 -2
- package/src/sync/git.ts +1 -1
- package/src/sync/layout.ts +2 -2
- package/src/sync/stamp.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/validate.ts +2 -2
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +1 -1
- package/src/tasks/label.ts +1 -1
- package/src/tasks/reach.ts +1 -1
- package/src/tasks/validate.ts +38 -14
- package/src/teach/nav.ts +1 -1
- package/src/teach/workspace.ts +1 -1
- package/src/tooling/inject.ts +2 -2
- package/src/tooling/read.ts +1 -1
- package/src/tooling/scan.ts +1 -1
- package/src/tooling/verify-stack.ts +1 -1
- package/src/version/installed.ts +1 -1
- package/src/version/skew.ts +1 -1
- package/standards/board.md +3 -3
- package/standards/plan.md +13 -7
- package/tsconfig.json +1 -1
- /package/src/{cli-run.ts → cli/run.ts} +0 -0
- /package/src/{ui.ts → cli/ui.ts} +0 -0
- /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
- /package/src/{surface-root.ts → roots/surface.ts} +0 -0
|
@@ -21,6 +21,8 @@ export interface Scrub {
|
|
|
21
21
|
readonly begin: () => ScrubSession
|
|
22
22
|
/** Holds a scrubbed number inside what the property accepts. */
|
|
23
23
|
readonly clamp: (value: number) => number
|
|
24
|
+
/** Scales each pixel's move, for a value whose useful range is small. */
|
|
25
|
+
readonly step?: number
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
interface FieldProps {
|
|
@@ -120,8 +122,11 @@ export function Field({
|
|
|
120
122
|
const moved = event.clientX - held.lastX
|
|
121
123
|
held.lastX = event.clientX
|
|
122
124
|
held.value = scrub.clamp(
|
|
123
|
-
Math.round(
|
|
124
|
-
|
|
125
|
+
Math.round(
|
|
126
|
+
(held.value +
|
|
127
|
+
moved * scrubStep(event.shiftKey) * (scrub.step ?? 1)) *
|
|
128
|
+
10,
|
|
129
|
+
) / 10,
|
|
125
130
|
)
|
|
126
131
|
const shown = String(held.value)
|
|
127
132
|
if (input.current) input.current.value = shown
|
|
@@ -177,6 +182,60 @@ export function Field({
|
|
|
177
182
|
)
|
|
178
183
|
}
|
|
179
184
|
|
|
185
|
+
interface SelectFieldProps {
|
|
186
|
+
readonly label: string
|
|
187
|
+
readonly glyph: string
|
|
188
|
+
/** Each option's value, which is also what it shows. */
|
|
189
|
+
readonly options: readonly string[]
|
|
190
|
+
readonly value: string
|
|
191
|
+
readonly isBusy: boolean
|
|
192
|
+
readonly isWide?: boolean
|
|
193
|
+
readonly onCommit: (value: string) => void
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A value picked from a fixed list, drawn like a field with a chevron. The
|
|
198
|
+
* select is native, so its list opens above the panel's clipping. A current
|
|
199
|
+
* value outside the list stays as an option, so it shows rather than blanks.
|
|
200
|
+
*/
|
|
201
|
+
export function SelectField({
|
|
202
|
+
label,
|
|
203
|
+
glyph,
|
|
204
|
+
options,
|
|
205
|
+
value,
|
|
206
|
+
isBusy,
|
|
207
|
+
isWide,
|
|
208
|
+
onCommit,
|
|
209
|
+
}: SelectFieldProps): JSX.Element {
|
|
210
|
+
const shown = options.includes(value) ? options : [...options, value]
|
|
211
|
+
return (
|
|
212
|
+
<div class={fieldClass(isWide, 'is-select')}>
|
|
213
|
+
<span class="glyph" aria-hidden="true" title={label}>
|
|
214
|
+
{glyph}
|
|
215
|
+
</span>
|
|
216
|
+
<select
|
|
217
|
+
class="glyph-field-input glyph-select"
|
|
218
|
+
aria-label={label}
|
|
219
|
+
value={value}
|
|
220
|
+
disabled={isBusy}
|
|
221
|
+
onChange={(event) => {
|
|
222
|
+
const picked = event.currentTarget.value
|
|
223
|
+
if (picked !== value) onCommit(picked)
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
{shown.map((option) => (
|
|
227
|
+
<option key={option} value={option}>
|
|
228
|
+
{option}
|
|
229
|
+
</option>
|
|
230
|
+
))}
|
|
231
|
+
</select>
|
|
232
|
+
<svg class="select-chevron" viewBox="0 0 16 16" aria-hidden="true">
|
|
233
|
+
<path d="M4.5 6.5 8 10l3.5-3.5" />
|
|
234
|
+
</svg>
|
|
235
|
+
</div>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
180
239
|
interface ReadOnlyFieldProps {
|
|
181
240
|
readonly label: string
|
|
182
241
|
readonly glyph: string
|
|
@@ -679,3 +679,81 @@
|
|
|
679
679
|
height: 14px;
|
|
680
680
|
fill: currentColor;
|
|
681
681
|
}
|
|
682
|
+
|
|
683
|
+
/*
|
|
684
|
+
* A value picked from a fixed list. The native select draws its value as the
|
|
685
|
+
* field's text and the chevron sits over its right edge, so the platform list
|
|
686
|
+
* opens on press and the field keeps the height of every other one.
|
|
687
|
+
*/
|
|
688
|
+
.glyph-field.is-select {
|
|
689
|
+
position: relative;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
.glyph-select {
|
|
693
|
+
appearance: none;
|
|
694
|
+
padding-right: 22px;
|
|
695
|
+
cursor: pointer;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.select-chevron {
|
|
699
|
+
position: absolute;
|
|
700
|
+
right: 6px;
|
|
701
|
+
width: 12px;
|
|
702
|
+
height: 12px;
|
|
703
|
+
fill: none;
|
|
704
|
+
stroke: var(--color-muted);
|
|
705
|
+
stroke-width: 1.5;
|
|
706
|
+
pointer-events: none;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/* A raw marker inside a field sits before its menu, clear of the value. */
|
|
710
|
+
.glyph-field .raw {
|
|
711
|
+
padding: 0 2px 0 var(--space-xs);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/* Mutually exclusive choices for one value, drawn as one field split evenly. */
|
|
715
|
+
.segmented {
|
|
716
|
+
display: grid;
|
|
717
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
718
|
+
gap: 2px;
|
|
719
|
+
min-height: 24px;
|
|
720
|
+
padding: 1px;
|
|
721
|
+
border: 1px solid var(--color-border);
|
|
722
|
+
border-radius: var(--radius-action);
|
|
723
|
+
background: var(--color-surface);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
.segment {
|
|
727
|
+
display: grid;
|
|
728
|
+
place-items: center;
|
|
729
|
+
min-height: 22px;
|
|
730
|
+
padding: 0;
|
|
731
|
+
border: 0;
|
|
732
|
+
border-radius: var(--radius-action);
|
|
733
|
+
background: transparent;
|
|
734
|
+
color: var(--color-muted);
|
|
735
|
+
cursor: pointer;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
.segment:hover {
|
|
739
|
+
color: var(--color-text);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
.segment[aria-pressed='true'] {
|
|
743
|
+
background: var(--color-chrome);
|
|
744
|
+
color: var(--color-text);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.segment:disabled {
|
|
748
|
+
opacity: 0.5;
|
|
749
|
+
cursor: not-allowed;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.segment svg {
|
|
753
|
+
width: 14px;
|
|
754
|
+
height: 14px;
|
|
755
|
+
fill: none;
|
|
756
|
+
stroke: currentColor;
|
|
757
|
+
stroke-width: 1.5;
|
|
758
|
+
stroke-linecap: round;
|
|
759
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX } from 'preact'
|
|
3
|
+
import { Field, type Scrub } from '@/canvas/client/inspector/field'
|
|
4
|
+
|
|
5
|
+
/** The menu entry that hands the value back to the field for typing. */
|
|
6
|
+
const RAW = ''
|
|
7
|
+
|
|
8
|
+
interface TokenSelectProps {
|
|
9
|
+
readonly label: string
|
|
10
|
+
readonly glyph: string
|
|
11
|
+
/** The value as the panel shows it, a token's name where one stands. */
|
|
12
|
+
readonly initial: string
|
|
13
|
+
/** One token group's names, in the order the token sheet sorts them. */
|
|
14
|
+
readonly tokens: readonly string[]
|
|
15
|
+
/** The token the element names, where it names one. */
|
|
16
|
+
readonly token?: string
|
|
17
|
+
readonly isRaw: boolean
|
|
18
|
+
readonly isBusy: boolean
|
|
19
|
+
readonly isWide?: boolean
|
|
20
|
+
readonly placeholder?: string
|
|
21
|
+
/** Takes a typed value as typed. */
|
|
22
|
+
readonly onCommit: (value: string) => void
|
|
23
|
+
/** Takes the name of a token picked from the menu or typed whole. */
|
|
24
|
+
readonly onPick: (name: string) => void
|
|
25
|
+
readonly scrub?: Scrub
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A value that takes a token or a typed literal. The menu lists the group's
|
|
30
|
+
* tokens first and a raw value last, which returns focus to the field, so a
|
|
31
|
+
* token is one pick away and a literal is still one keystroke. A group with no
|
|
32
|
+
* tokens leaves the field alone, with no menu and no raw marker.
|
|
33
|
+
*/
|
|
34
|
+
export function TokenSelect({
|
|
35
|
+
label,
|
|
36
|
+
glyph,
|
|
37
|
+
initial,
|
|
38
|
+
tokens,
|
|
39
|
+
token,
|
|
40
|
+
isRaw,
|
|
41
|
+
isBusy,
|
|
42
|
+
isWide,
|
|
43
|
+
placeholder,
|
|
44
|
+
onCommit,
|
|
45
|
+
onPick,
|
|
46
|
+
scrub,
|
|
47
|
+
}: TokenSelectProps): JSX.Element {
|
|
48
|
+
const hasTokens = tokens.length > 0
|
|
49
|
+
return (
|
|
50
|
+
<Field
|
|
51
|
+
label={label}
|
|
52
|
+
glyph={glyph}
|
|
53
|
+
initial={initial}
|
|
54
|
+
isBusy={isBusy}
|
|
55
|
+
isWide={isWide}
|
|
56
|
+
placeholder={placeholder}
|
|
57
|
+
onCommit={(typed) => {
|
|
58
|
+
if (tokens.includes(typed)) onPick(typed)
|
|
59
|
+
else onCommit(typed)
|
|
60
|
+
}}
|
|
61
|
+
scrub={scrub}
|
|
62
|
+
trailing={
|
|
63
|
+
hasTokens ? (
|
|
64
|
+
<>
|
|
65
|
+
{isRaw ? (
|
|
66
|
+
<span
|
|
67
|
+
class="raw"
|
|
68
|
+
title="Set as a raw value, so it will not follow the theme. Pick a token to fix it"
|
|
69
|
+
>
|
|
70
|
+
raw
|
|
71
|
+
</span>
|
|
72
|
+
) : null}
|
|
73
|
+
<span class="size-mode">
|
|
74
|
+
<select
|
|
75
|
+
class="size-mode-select"
|
|
76
|
+
aria-label={`${label} tokens`}
|
|
77
|
+
value={token ?? RAW}
|
|
78
|
+
disabled={isBusy}
|
|
79
|
+
onChange={(event) => {
|
|
80
|
+
const picked = event.currentTarget.value
|
|
81
|
+
if (picked === RAW) {
|
|
82
|
+
event.currentTarget
|
|
83
|
+
.closest('.glyph-field')
|
|
84
|
+
?.querySelector<HTMLInputElement>('.glyph-field-input')
|
|
85
|
+
?.focus()
|
|
86
|
+
} else if (picked !== token) onPick(picked)
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
{tokens.map((name) => (
|
|
90
|
+
<option key={name} value={name}>
|
|
91
|
+
{name}
|
|
92
|
+
</option>
|
|
93
|
+
))}
|
|
94
|
+
<option value={RAW}>Raw value</option>
|
|
95
|
+
</select>
|
|
96
|
+
<svg
|
|
97
|
+
class="size-mode-chevron"
|
|
98
|
+
viewBox="0 0 16 16"
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
>
|
|
101
|
+
<path d="M4.5 6.5 8 10l3.5-3.5" />
|
|
102
|
+
</svg>
|
|
103
|
+
</span>
|
|
104
|
+
</>
|
|
105
|
+
) : null
|
|
106
|
+
}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* back through `toCssValue`, so rounding never writes a value nobody typed.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/** A length that runs below zero, so a bare number is pixels and unclamped. */
|
|
8
|
+
const SIGNED_LENGTHS = new Set(['letter-spacing'])
|
|
9
|
+
|
|
7
10
|
/** Properties whose bare numbers are pixel lengths. */
|
|
8
11
|
const LENGTHS = new Set([
|
|
9
12
|
'width',
|
|
@@ -26,6 +29,8 @@ const SIZE_MODES: Readonly<Record<string, string>> = {
|
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
const PX = /^(-?\d*\.?\d+)px$/
|
|
32
|
+
const EM = /^(-?\d*\.?\d+)em$/
|
|
33
|
+
const NUMBER = /^-?\d*\.?\d+$/
|
|
29
34
|
|
|
30
35
|
function formatPx(token: string): string {
|
|
31
36
|
const match = token.match(PX)
|
|
@@ -45,6 +50,11 @@ export function isNoFill(value: string): boolean {
|
|
|
45
50
|
export function displayValue(property: string, raw: string): string {
|
|
46
51
|
const value = raw.trim()
|
|
47
52
|
if (property === 'gap' && (value === '' || value === 'normal')) return ''
|
|
53
|
+
if (property === 'letter-spacing') {
|
|
54
|
+
if (value === 'normal') return ''
|
|
55
|
+
const em = value.match(EM)
|
|
56
|
+
if (em) return `${Math.round(Number(em[1]) * 1000) / 1000}em`
|
|
57
|
+
}
|
|
48
58
|
if (property === 'background-color' && isNoFill(value)) return ''
|
|
49
59
|
if (property === 'opacity' && value !== '')
|
|
50
60
|
return `${Math.round(Number(value) * 100)}%`
|
|
@@ -61,9 +71,13 @@ export function displayValue(property: string, raw: string): string {
|
|
|
61
71
|
|
|
62
72
|
export function toCssValue(property: string, typed: string): string {
|
|
63
73
|
const value = typed.trim()
|
|
74
|
+
/* A line height has no auto keyword, so the field's Auto is its normal. */
|
|
75
|
+
if (property === 'line-height' && value.toLowerCase() === 'auto') {
|
|
76
|
+
return 'normal'
|
|
77
|
+
}
|
|
64
78
|
if (value === 'Auto' || value === 'Normal') return value.toLowerCase()
|
|
65
79
|
if (SIZES.has(property) && SIZE_MODES[value]) return SIZE_MODES[value]
|
|
66
|
-
if (!LENGTHS.has(property)) return value
|
|
80
|
+
if (!LENGTHS.has(property) && !SIGNED_LENGTHS.has(property)) return value
|
|
67
81
|
return value
|
|
68
82
|
.split(/\s+/)
|
|
69
83
|
.map((token) => (/^-?\d*\.?\d+$/.test(token) ? `${token}px` : token))
|
|
@@ -92,10 +106,68 @@ export function sharedRadius(corners: readonly string[]): string | undefined {
|
|
|
92
106
|
*/
|
|
93
107
|
export function clampScrub(property: string, value: number): number {
|
|
94
108
|
if (property === 'font-weight') return Math.min(1000, Math.max(1, value))
|
|
95
|
-
if (LENGTHS.has(property)
|
|
109
|
+
if (LENGTHS.has(property) || property === 'line-height') {
|
|
110
|
+
return Math.max(0, value)
|
|
111
|
+
}
|
|
112
|
+
return value
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A line height as the panel shows it: a ratio of the font size, the way a
|
|
117
|
+
* unitless value writes it. A browser computes a unitless value to pixels, so
|
|
118
|
+
* a pixel value reads back as its ratio when the font size is in pixels too.
|
|
119
|
+
*/
|
|
120
|
+
export function lineHeightValue(raw: string, fontSize: string): string {
|
|
121
|
+
const value = raw.trim()
|
|
122
|
+
if (value === 'normal') return 'Auto'
|
|
123
|
+
const ratio = (number: number): string =>
|
|
124
|
+
String(Math.round(number * 100) / 100)
|
|
125
|
+
if (NUMBER.test(value)) return ratio(Number(value))
|
|
126
|
+
const height = value.match(PX)
|
|
127
|
+
const size = fontSize.trim().match(PX)
|
|
128
|
+
if (height && size && Number(size[1]) > 0) {
|
|
129
|
+
return ratio(Number(height[1]) / Number(size[1]))
|
|
130
|
+
}
|
|
131
|
+
return value
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The first family of a stack, unquoted, which is the one the field names. */
|
|
135
|
+
export function firstFamily(stack: string): string {
|
|
136
|
+
const value = stack.trim()
|
|
137
|
+
if (value.startsWith('var(')) return value
|
|
138
|
+
const [first = ''] = value.split(',')
|
|
139
|
+
return first.trim().replace(/^(["'])(.*)\1$/, '$2')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Text alignment by the side it lands on, so the group presses the side the
|
|
144
|
+
* text paints on. The logical keywords turn on the element's direction.
|
|
145
|
+
*/
|
|
146
|
+
export function textAlignOf(computed: string, direction: string): string {
|
|
147
|
+
const value = computed.trim()
|
|
148
|
+
const isRtl = direction.trim() === 'rtl'
|
|
149
|
+
if (value === 'start') return isRtl ? 'right' : 'left'
|
|
150
|
+
if (value === 'end') return isRtl ? 'left' : 'right'
|
|
96
151
|
return value
|
|
97
152
|
}
|
|
98
153
|
|
|
154
|
+
/**
|
|
155
|
+
* A value as the writer takes it. A whole declaration pasted from a
|
|
156
|
+
* stylesheet keeps its value alone, since the writer refuses the semicolon.
|
|
157
|
+
*/
|
|
158
|
+
export function declarationValue(property: string, typed: string): string {
|
|
159
|
+
const value = typed.trim().replace(/;\s*$/, '')
|
|
160
|
+
const prefix = `${property}:`
|
|
161
|
+
return value.toLowerCase().startsWith(prefix)
|
|
162
|
+
? value.slice(prefix.length).trim()
|
|
163
|
+
: value
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The token a value names when it is a bare `var()` and nothing else. */
|
|
167
|
+
export function tokenOf(value: string): string | undefined {
|
|
168
|
+
return value.trim().match(VAR)?.[1]
|
|
169
|
+
}
|
|
170
|
+
|
|
99
171
|
/** Units a scrub moves for each pixel the pointer travels. */
|
|
100
172
|
export function scrubStep(isCoarse: boolean): number {
|
|
101
173
|
return isCoarse ? 10 : 1
|
|
@@ -8,16 +8,23 @@ import {
|
|
|
8
8
|
Field,
|
|
9
9
|
ReadOnlyField,
|
|
10
10
|
type Scrub,
|
|
11
|
+
SelectField,
|
|
11
12
|
} from '@/canvas/client/inspector/field'
|
|
12
13
|
import { Section } from '@/canvas/client/inspector/section'
|
|
13
14
|
import { SizeField } from '@/canvas/client/inspector/size-field'
|
|
15
|
+
import { TokenSelect } from '@/canvas/client/inspector/token-select'
|
|
14
16
|
import {
|
|
15
17
|
clampScrub,
|
|
18
|
+
declarationValue,
|
|
16
19
|
displayValue,
|
|
20
|
+
firstFamily,
|
|
21
|
+
lineHeightValue,
|
|
17
22
|
opacityToCss,
|
|
18
23
|
parseColor,
|
|
19
24
|
sharedRadius,
|
|
25
|
+
textAlignOf,
|
|
20
26
|
toCssValue,
|
|
27
|
+
tokenOf,
|
|
21
28
|
} from '@/canvas/client/inspector/values'
|
|
22
29
|
import {
|
|
23
30
|
currentPage,
|
|
@@ -27,6 +34,7 @@ import {
|
|
|
27
34
|
frameDocuments,
|
|
28
35
|
frameKey,
|
|
29
36
|
frameVersions,
|
|
37
|
+
newStep,
|
|
30
38
|
pendingEdit,
|
|
31
39
|
savedEdit,
|
|
32
40
|
selectedElement,
|
|
@@ -34,6 +42,7 @@ import {
|
|
|
34
42
|
tokens,
|
|
35
43
|
writeError,
|
|
36
44
|
} from '@/canvas/client/state'
|
|
45
|
+
import type { TokenKind } from '@/canvas/tokens'
|
|
37
46
|
import '@/canvas/client/inspector/fields.css'
|
|
38
47
|
|
|
39
48
|
/** Elements the parser never gives content, so they hold no text to edit. */
|
|
@@ -112,11 +121,26 @@ const CORNER_FIELDS: readonly StyleField[] = [
|
|
|
112
121
|
const FLEX_DISPLAYS = new Set(['flex', 'inline-flex'])
|
|
113
122
|
const GRID_DISPLAYS = new Set(['grid', 'inline-grid'])
|
|
114
123
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
124
|
+
const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900']
|
|
125
|
+
|
|
126
|
+
/** Line height and letter spacing move in tenths, since their range is small. */
|
|
127
|
+
const SPACING_FIELDS: readonly StyleField[] = [
|
|
128
|
+
{ label: 'line height', glyph: 'LH', property: 'line-height' },
|
|
129
|
+
{
|
|
130
|
+
label: 'letter spacing',
|
|
131
|
+
glyph: 'LS',
|
|
132
|
+
property: 'letter-spacing',
|
|
133
|
+
placeholder: '0',
|
|
134
|
+
},
|
|
118
135
|
]
|
|
119
136
|
|
|
137
|
+
const TEXT_ALIGNS = [
|
|
138
|
+
{ value: 'left', path: 'M2.5 4h11M2.5 8h7M2.5 12h9' },
|
|
139
|
+
{ value: 'center', path: 'M2.5 4h11M4.5 8h7M3.5 12h9' },
|
|
140
|
+
{ value: 'right', path: 'M2.5 4h11M6.5 8h7M4.5 12h9' },
|
|
141
|
+
{ value: 'justify', path: 'M2.5 4h11M2.5 8h11M2.5 12h11' },
|
|
142
|
+
] as const
|
|
143
|
+
|
|
120
144
|
const FILL_FIELDS = [
|
|
121
145
|
{ label: 'color', property: 'color' },
|
|
122
146
|
{ label: 'background', property: 'background-color' },
|
|
@@ -217,17 +241,25 @@ function ElementFields({
|
|
|
217
241
|
}
|
|
218
242
|
/*
|
|
219
243
|
* The writer takes one property an edit, so a control setting two posts
|
|
220
|
-
* them in turn and stops at the first refusal
|
|
221
|
-
* last answered with in its own address,
|
|
222
|
-
* 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.
|
|
223
247
|
*/
|
|
224
248
|
const commitAll = async (
|
|
225
249
|
changes: readonly (readonly [string, string])[],
|
|
226
250
|
): Promise<void> => {
|
|
251
|
+
const step = newStep()
|
|
227
252
|
let current = address
|
|
228
253
|
for (const [property, value] of changes) {
|
|
229
254
|
if (!current) return
|
|
230
|
-
const written = await editElement(
|
|
255
|
+
const written = await editElement(
|
|
256
|
+
frameRef,
|
|
257
|
+
key,
|
|
258
|
+
current,
|
|
259
|
+
property,
|
|
260
|
+
value,
|
|
261
|
+
step,
|
|
262
|
+
)
|
|
231
263
|
if (!written) return
|
|
232
264
|
if (written.hash) current = { ...current, hash: written.hash }
|
|
233
265
|
}
|
|
@@ -241,20 +273,29 @@ function ElementFields({
|
|
|
241
273
|
const isFlex = FLEX_DISPLAYS.has(display)
|
|
242
274
|
const isWrapped = computedValue(node, 'flex-wrap').trim() === 'wrap'
|
|
243
275
|
const rect = node.getBoundingClientRect()
|
|
244
|
-
const font = currentValue(node, 'font-family')
|
|
245
276
|
const text = node.textContent ?? ''
|
|
246
|
-
const
|
|
277
|
+
const groupTokens = (kind: TokenKind): readonly string[] =>
|
|
247
278
|
tokens.value?.groups
|
|
248
|
-
?.find((group) => group.kind ===
|
|
279
|
+
?.find((group) => group.kind === kind)
|
|
249
280
|
?.tokens.map((token) => token.name) ?? []
|
|
281
|
+
const colorTokens = groupTokens('color')
|
|
282
|
+
const family = currentValue(node, 'font-family')
|
|
283
|
+
const familyToken = tokenOf(family)
|
|
284
|
+
const size = currentValue(node, 'font-size')
|
|
285
|
+
const sizeToken = tokenOf(size)
|
|
286
|
+
const textAlign = textAlignOf(
|
|
287
|
+
computedValue(node, 'text-align'),
|
|
288
|
+
computedValue(node, 'direction'),
|
|
289
|
+
)
|
|
250
290
|
|
|
251
291
|
/**
|
|
252
292
|
* Previews into the frame's own inline style and writes through the same
|
|
253
293
|
* edit as typing. A reload mid-drag replaces the document, so a release
|
|
254
294
|
* against one no longer on screen posts nothing.
|
|
255
295
|
*/
|
|
256
|
-
const scrubOf = (property: string): Scrub => ({
|
|
296
|
+
const scrubOf = (property: string, step?: number): Scrub => ({
|
|
257
297
|
clamp: (value) => clampScrub(property, value),
|
|
298
|
+
step,
|
|
258
299
|
begin: () => {
|
|
259
300
|
const original = inlineValue(node, property)
|
|
260
301
|
const style = (node as HTMLElement).style
|
|
@@ -422,10 +463,88 @@ function ElementFields({
|
|
|
422
463
|
)}
|
|
423
464
|
</Section>
|
|
424
465
|
<Section title="Typography">
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
466
|
+
<TokenSelect
|
|
467
|
+
label="family"
|
|
468
|
+
glyph="Aa"
|
|
469
|
+
initial={familyToken ?? firstFamily(family)}
|
|
470
|
+
tokens={groupTokens('font-family')}
|
|
471
|
+
token={familyToken}
|
|
472
|
+
isRaw={isRawInline(node, 'font-family')}
|
|
473
|
+
isBusy={isBusy}
|
|
474
|
+
isWide
|
|
475
|
+
onCommit={(typed) =>
|
|
476
|
+
commit('font-family')(declarationValue('font-family', typed))
|
|
477
|
+
}
|
|
478
|
+
onPick={(name) => commit('font-family')(`var(${name})`)}
|
|
479
|
+
/>
|
|
480
|
+
<TokenSelect
|
|
481
|
+
label="size"
|
|
482
|
+
glyph="Size"
|
|
483
|
+
initial={sizeToken ?? displayValue('font-size', size)}
|
|
484
|
+
tokens={groupTokens('font-size')}
|
|
485
|
+
token={sizeToken}
|
|
486
|
+
isRaw={isRawInline(node, 'font-size')}
|
|
487
|
+
isBusy={isBusy}
|
|
488
|
+
onCommit={(typed) =>
|
|
489
|
+
commit('font-size')(toCssValue('font-size', typed))
|
|
490
|
+
}
|
|
491
|
+
onPick={(name) => commit('font-size')(`var(${name})`)}
|
|
492
|
+
scrub={'style' in node ? scrubOf('font-size') : undefined}
|
|
493
|
+
/>
|
|
494
|
+
<SelectField
|
|
495
|
+
label="weight"
|
|
496
|
+
glyph="Wt"
|
|
497
|
+
options={WEIGHTS}
|
|
498
|
+
value={currentValue(node, 'font-weight').trim()}
|
|
499
|
+
isBusy={isBusy}
|
|
500
|
+
onCommit={commit('font-weight')}
|
|
501
|
+
/>
|
|
502
|
+
{SPACING_FIELDS.map((field) => (
|
|
503
|
+
<Field
|
|
504
|
+
key={field.property}
|
|
505
|
+
label={field.label}
|
|
506
|
+
glyph={field.glyph}
|
|
507
|
+
initial={
|
|
508
|
+
field.property === 'line-height'
|
|
509
|
+
? lineHeightValue(
|
|
510
|
+
currentValue(node, 'line-height'),
|
|
511
|
+
computedValue(node, 'font-size'),
|
|
512
|
+
)
|
|
513
|
+
: displayValue(
|
|
514
|
+
field.property,
|
|
515
|
+
currentValue(node, field.property),
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
isBusy={isBusy}
|
|
519
|
+
placeholder={field.placeholder}
|
|
520
|
+
onCommit={(typed) =>
|
|
521
|
+
commit(field.property)(toCssValue(field.property, typed))
|
|
522
|
+
}
|
|
523
|
+
scrub={'style' in node ? scrubOf(field.property, 0.1) : undefined}
|
|
524
|
+
/>
|
|
525
|
+
))}
|
|
526
|
+
<div class="segmented is-wide" role="group" aria-label="text alignment">
|
|
527
|
+
{TEXT_ALIGNS.map((align) => (
|
|
528
|
+
<button
|
|
529
|
+
key={align.value}
|
|
530
|
+
type="button"
|
|
531
|
+
class="segment"
|
|
532
|
+
aria-label={`Align ${align.value}`}
|
|
533
|
+
title={`Align ${align.value}`}
|
|
534
|
+
aria-pressed={textAlign === align.value}
|
|
535
|
+
disabled={isBusy}
|
|
536
|
+
onClick={() => {
|
|
537
|
+
if (textAlign !== align.value) {
|
|
538
|
+
commit('text-align')(align.value)
|
|
539
|
+
}
|
|
540
|
+
}}
|
|
541
|
+
>
|
|
542
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
543
|
+
<path d={align.path} />
|
|
544
|
+
</svg>
|
|
545
|
+
</button>
|
|
546
|
+
))}
|
|
547
|
+
</div>
|
|
429
548
|
</Section>
|
|
430
549
|
<Section title="Fill">
|
|
431
550
|
{FILL_FIELDS.map((field) => {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas key layer: one map from a key event to a surface action, which
|
|
3
|
+
* the surface and every loaded frame document call. It sees only what the
|
|
4
|
+
* surface forwards, so a key typed into a panel field never reaches it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type SurfaceAction =
|
|
8
|
+
| 'tool-move'
|
|
9
|
+
| 'tool-pan'
|
|
10
|
+
| 'zoom-in'
|
|
11
|
+
| 'zoom-out'
|
|
12
|
+
| 'zoom-fit'
|
|
13
|
+
| 'pan-hold'
|
|
14
|
+
| 'pan-release'
|
|
15
|
+
| 'undo'
|
|
16
|
+
| 'redo'
|
|
17
|
+
| 'panels-toggle'
|
|
18
|
+
|
|
19
|
+
/** The fields of a `KeyboardEvent` the map reads, so a test needs no DOM. */
|
|
20
|
+
export interface KeyInput {
|
|
21
|
+
readonly type: string
|
|
22
|
+
readonly key: string
|
|
23
|
+
readonly code: string
|
|
24
|
+
readonly shiftKey: boolean
|
|
25
|
+
readonly ctrlKey: boolean
|
|
26
|
+
readonly metaKey: boolean
|
|
27
|
+
readonly altKey: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PLAIN_KEYS: Readonly<Record<string, SurfaceAction | undefined>> = {
|
|
31
|
+
v: 'tool-move',
|
|
32
|
+
h: 'tool-pan',
|
|
33
|
+
'=': 'zoom-in',
|
|
34
|
+
'+': 'zoom-in',
|
|
35
|
+
'-': 'zoom-out',
|
|
36
|
+
' ': 'pan-hold',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
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.
|
|
57
|
+
*/
|
|
58
|
+
export function surfaceAction(event: KeyInput): SurfaceAction | undefined {
|
|
59
|
+
if (event.type === 'keyup') {
|
|
60
|
+
return event.key === ' ' ? 'pan-release' : undefined
|
|
61
|
+
}
|
|
62
|
+
if (event.type !== 'keydown') return undefined
|
|
63
|
+
if (event.ctrlKey || event.metaKey) return historyAction(event)
|
|
64
|
+
if (event.altKey) return undefined
|
|
65
|
+
if (event.shiftKey) {
|
|
66
|
+
if (event.code === 'Digit1') return 'zoom-fit'
|
|
67
|
+
return event.key === '+' ? 'zoom-in' : undefined
|
|
68
|
+
}
|
|
69
|
+
if (event.code === 'Backslash') return 'panels-toggle'
|
|
70
|
+
return PLAIN_KEYS[event.key.toLowerCase()]
|
|
71
|
+
}
|
|
@@ -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 />
|