@erclx/canon 5.27.0 → 5.28.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 +13 -3
- 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 +4 -4
- package/docs/agents/gate.md +3 -1
- package/docs/agents/index.md +2 -1
- package/docs/agents/pr-evidence.md +9 -8
- 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/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 +121 -11
- package/src/canvas/client/keys.ts +54 -0
- package/src/canvas/client/selection.tsx +3 -1
- package/src/canvas/client/shell.css +108 -2
- package/src/canvas/client/state.ts +96 -5
- package/src/canvas/client/surface.tsx +194 -17
- package/src/canvas/client/tools.tsx +64 -0
- package/src/canvas/content.ts +1 -1
- package/src/canvas/edit.ts +12 -2
- package/src/canvas/tokens.ts +1 -1
- package/src/claude/seeds.ts +2 -2
- package/src/claude/skills-reach.ts +1 -1
- package/src/commands/canvas.ts +1 -1
- package/src/commands/claude.ts +3 -3
- package/src/commands/design.ts +3 -3
- package/src/commands/docs.ts +1 -1
- package/src/commands/feedback.ts +2 -2
- package/src/commands/gate.ts +9 -2
- package/src/commands/gov.ts +2 -2
- package/src/commands/init.ts +1 -1
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pr.ts +183 -7
- package/src/commands/sandbox.ts +10 -13
- package/src/commands/slides.ts +1 -1
- package/src/commands/sync.ts +1 -1
- package/src/commands/tasks.ts +19 -2
- 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/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/exec.ts +1 -1
- package/src/feedback/github.ts +1 -1
- package/src/gate/measures.ts +1 -1
- package/src/gate/sequencer.ts +55 -3
- package/src/gate/stages.ts +8 -0
- package/src/gate/suite-lock.ts +214 -0
- 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/scratch-evidence.ts +1 -1
- package/src/migrate/surface-roots.ts +6 -6
- package/src/pr/evidence.ts +154 -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} +1 -1
- 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/hooks.ts +1 -1
- package/src/sandbox/provision.ts +78 -28
- package/src/sandbox/scenario.ts +588 -0
- 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 +1 -1
- package/src/sync/layout.ts +2 -2
- package/src/sync/stamp.ts +1 -1
- package/src/targets/validate.ts +1 -1
- 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 +1 -1
- package/src/tooling/read.ts +1 -1
- package/src/tooling/scan.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/{legacy-path.ts → roots/legacy.ts} +0 -0
- /package/src/{surface-root.ts → roots/surface.ts} +0 -0
|
@@ -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,
|
|
@@ -34,6 +41,7 @@ import {
|
|
|
34
41
|
tokens,
|
|
35
42
|
writeError,
|
|
36
43
|
} from '@/canvas/client/state'
|
|
44
|
+
import type { TokenKind } from '@/canvas/tokens'
|
|
37
45
|
import '@/canvas/client/inspector/fields.css'
|
|
38
46
|
|
|
39
47
|
/** Elements the parser never gives content, so they hold no text to edit. */
|
|
@@ -112,11 +120,26 @@ const CORNER_FIELDS: readonly StyleField[] = [
|
|
|
112
120
|
const FLEX_DISPLAYS = new Set(['flex', 'inline-flex'])
|
|
113
121
|
const GRID_DISPLAYS = new Set(['grid', 'inline-grid'])
|
|
114
122
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900']
|
|
124
|
+
|
|
125
|
+
/** Line height and letter spacing move in tenths, since their range is small. */
|
|
126
|
+
const SPACING_FIELDS: readonly StyleField[] = [
|
|
127
|
+
{ label: 'line height', glyph: 'LH', property: 'line-height' },
|
|
128
|
+
{
|
|
129
|
+
label: 'letter spacing',
|
|
130
|
+
glyph: 'LS',
|
|
131
|
+
property: 'letter-spacing',
|
|
132
|
+
placeholder: '0',
|
|
133
|
+
},
|
|
118
134
|
]
|
|
119
135
|
|
|
136
|
+
const TEXT_ALIGNS = [
|
|
137
|
+
{ value: 'left', path: 'M2.5 4h11M2.5 8h7M2.5 12h9' },
|
|
138
|
+
{ value: 'center', path: 'M2.5 4h11M4.5 8h7M3.5 12h9' },
|
|
139
|
+
{ value: 'right', path: 'M2.5 4h11M6.5 8h7M4.5 12h9' },
|
|
140
|
+
{ value: 'justify', path: 'M2.5 4h11M2.5 8h11M2.5 12h11' },
|
|
141
|
+
] as const
|
|
142
|
+
|
|
120
143
|
const FILL_FIELDS = [
|
|
121
144
|
{ label: 'color', property: 'color' },
|
|
122
145
|
{ label: 'background', property: 'background-color' },
|
|
@@ -241,20 +264,29 @@ function ElementFields({
|
|
|
241
264
|
const isFlex = FLEX_DISPLAYS.has(display)
|
|
242
265
|
const isWrapped = computedValue(node, 'flex-wrap').trim() === 'wrap'
|
|
243
266
|
const rect = node.getBoundingClientRect()
|
|
244
|
-
const font = currentValue(node, 'font-family')
|
|
245
267
|
const text = node.textContent ?? ''
|
|
246
|
-
const
|
|
268
|
+
const groupTokens = (kind: TokenKind): readonly string[] =>
|
|
247
269
|
tokens.value?.groups
|
|
248
|
-
?.find((group) => group.kind ===
|
|
270
|
+
?.find((group) => group.kind === kind)
|
|
249
271
|
?.tokens.map((token) => token.name) ?? []
|
|
272
|
+
const colorTokens = groupTokens('color')
|
|
273
|
+
const family = currentValue(node, 'font-family')
|
|
274
|
+
const familyToken = tokenOf(family)
|
|
275
|
+
const size = currentValue(node, 'font-size')
|
|
276
|
+
const sizeToken = tokenOf(size)
|
|
277
|
+
const textAlign = textAlignOf(
|
|
278
|
+
computedValue(node, 'text-align'),
|
|
279
|
+
computedValue(node, 'direction'),
|
|
280
|
+
)
|
|
250
281
|
|
|
251
282
|
/**
|
|
252
283
|
* Previews into the frame's own inline style and writes through the same
|
|
253
284
|
* edit as typing. A reload mid-drag replaces the document, so a release
|
|
254
285
|
* against one no longer on screen posts nothing.
|
|
255
286
|
*/
|
|
256
|
-
const scrubOf = (property: string): Scrub => ({
|
|
287
|
+
const scrubOf = (property: string, step?: number): Scrub => ({
|
|
257
288
|
clamp: (value) => clampScrub(property, value),
|
|
289
|
+
step,
|
|
258
290
|
begin: () => {
|
|
259
291
|
const original = inlineValue(node, property)
|
|
260
292
|
const style = (node as HTMLElement).style
|
|
@@ -422,10 +454,88 @@ function ElementFields({
|
|
|
422
454
|
)}
|
|
423
455
|
</Section>
|
|
424
456
|
<Section title="Typography">
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
457
|
+
<TokenSelect
|
|
458
|
+
label="family"
|
|
459
|
+
glyph="Aa"
|
|
460
|
+
initial={familyToken ?? firstFamily(family)}
|
|
461
|
+
tokens={groupTokens('font-family')}
|
|
462
|
+
token={familyToken}
|
|
463
|
+
isRaw={isRawInline(node, 'font-family')}
|
|
464
|
+
isBusy={isBusy}
|
|
465
|
+
isWide
|
|
466
|
+
onCommit={(typed) =>
|
|
467
|
+
commit('font-family')(declarationValue('font-family', typed))
|
|
468
|
+
}
|
|
469
|
+
onPick={(name) => commit('font-family')(`var(${name})`)}
|
|
470
|
+
/>
|
|
471
|
+
<TokenSelect
|
|
472
|
+
label="size"
|
|
473
|
+
glyph="Size"
|
|
474
|
+
initial={sizeToken ?? displayValue('font-size', size)}
|
|
475
|
+
tokens={groupTokens('font-size')}
|
|
476
|
+
token={sizeToken}
|
|
477
|
+
isRaw={isRawInline(node, 'font-size')}
|
|
478
|
+
isBusy={isBusy}
|
|
479
|
+
onCommit={(typed) =>
|
|
480
|
+
commit('font-size')(toCssValue('font-size', typed))
|
|
481
|
+
}
|
|
482
|
+
onPick={(name) => commit('font-size')(`var(${name})`)}
|
|
483
|
+
scrub={'style' in node ? scrubOf('font-size') : undefined}
|
|
484
|
+
/>
|
|
485
|
+
<SelectField
|
|
486
|
+
label="weight"
|
|
487
|
+
glyph="Wt"
|
|
488
|
+
options={WEIGHTS}
|
|
489
|
+
value={currentValue(node, 'font-weight').trim()}
|
|
490
|
+
isBusy={isBusy}
|
|
491
|
+
onCommit={commit('font-weight')}
|
|
492
|
+
/>
|
|
493
|
+
{SPACING_FIELDS.map((field) => (
|
|
494
|
+
<Field
|
|
495
|
+
key={field.property}
|
|
496
|
+
label={field.label}
|
|
497
|
+
glyph={field.glyph}
|
|
498
|
+
initial={
|
|
499
|
+
field.property === 'line-height'
|
|
500
|
+
? lineHeightValue(
|
|
501
|
+
currentValue(node, 'line-height'),
|
|
502
|
+
computedValue(node, 'font-size'),
|
|
503
|
+
)
|
|
504
|
+
: displayValue(
|
|
505
|
+
field.property,
|
|
506
|
+
currentValue(node, field.property),
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
isBusy={isBusy}
|
|
510
|
+
placeholder={field.placeholder}
|
|
511
|
+
onCommit={(typed) =>
|
|
512
|
+
commit(field.property)(toCssValue(field.property, typed))
|
|
513
|
+
}
|
|
514
|
+
scrub={'style' in node ? scrubOf(field.property, 0.1) : undefined}
|
|
515
|
+
/>
|
|
516
|
+
))}
|
|
517
|
+
<div class="segmented is-wide" role="group" aria-label="text alignment">
|
|
518
|
+
{TEXT_ALIGNS.map((align) => (
|
|
519
|
+
<button
|
|
520
|
+
key={align.value}
|
|
521
|
+
type="button"
|
|
522
|
+
class="segment"
|
|
523
|
+
aria-label={`Align ${align.value}`}
|
|
524
|
+
title={`Align ${align.value}`}
|
|
525
|
+
aria-pressed={textAlign === align.value}
|
|
526
|
+
disabled={isBusy}
|
|
527
|
+
onClick={() => {
|
|
528
|
+
if (textAlign !== align.value) {
|
|
529
|
+
commit('text-align')(align.value)
|
|
530
|
+
}
|
|
531
|
+
}}
|
|
532
|
+
>
|
|
533
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
534
|
+
<path d={align.path} />
|
|
535
|
+
</svg>
|
|
536
|
+
</button>
|
|
537
|
+
))}
|
|
538
|
+
</div>
|
|
429
539
|
</Section>
|
|
430
540
|
<Section title="Fill">
|
|
431
541
|
{FILL_FIELDS.map((field) => {
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
|
|
16
|
+
/** The fields of a `KeyboardEvent` the map reads, so a test needs no DOM. */
|
|
17
|
+
export interface KeyInput {
|
|
18
|
+
readonly type: string
|
|
19
|
+
readonly key: string
|
|
20
|
+
readonly code: string
|
|
21
|
+
readonly shiftKey: boolean
|
|
22
|
+
readonly ctrlKey: boolean
|
|
23
|
+
readonly metaKey: boolean
|
|
24
|
+
readonly altKey: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PLAIN_KEYS: Readonly<Record<string, SurfaceAction | undefined>> = {
|
|
28
|
+
v: 'tool-move',
|
|
29
|
+
h: 'tool-pan',
|
|
30
|
+
'=': 'zoom-in',
|
|
31
|
+
'+': 'zoom-in',
|
|
32
|
+
'-': 'zoom-out',
|
|
33
|
+
' ': 'pan-hold',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cmd, Ctrl, and Alt combinations are the browser's or the system's, so any
|
|
38
|
+
* of them held on a press returns nothing. Space coming up releases whatever
|
|
39
|
+
* is held with it, or a modifier pressed mid-pan would leave the pan stuck.
|
|
40
|
+
* Shift and the 1 key fits by `code`, since the character Shift gives on that
|
|
41
|
+
* key differs by layout.
|
|
42
|
+
*/
|
|
43
|
+
export function surfaceAction(event: KeyInput): SurfaceAction | undefined {
|
|
44
|
+
if (event.type === 'keyup') {
|
|
45
|
+
return event.key === ' ' ? 'pan-release' : undefined
|
|
46
|
+
}
|
|
47
|
+
if (event.type !== 'keydown') return undefined
|
|
48
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return undefined
|
|
49
|
+
if (event.shiftKey) {
|
|
50
|
+
if (event.code === 'Digit1') return 'zoom-fit'
|
|
51
|
+
return event.key === '+' ? 'zoom-in' : undefined
|
|
52
|
+
}
|
|
53
|
+
return PLAIN_KEYS[event.key.toLowerCase()]
|
|
54
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource preact */
|
|
2
2
|
import type { JSX } from 'preact'
|
|
3
3
|
import { useRef, useState } from 'preact/hooks'
|
|
4
|
-
import { view } from '@/canvas/client/state'
|
|
4
|
+
import { shownTool, view } from '@/canvas/client/state'
|
|
5
5
|
|
|
6
6
|
/** Screen pixels a press travels before it reads as a drag and not a click. */
|
|
7
7
|
export const DRAG_THRESHOLD = 3
|
|
@@ -97,6 +97,8 @@ export function SelectionOverlay({
|
|
|
97
97
|
|
|
98
98
|
const handlePointerDown = (corner: Corner) => (event: PointerEvent) => {
|
|
99
99
|
if (event.button !== 0) return
|
|
100
|
+
/* A pan drag starting on a handle belongs to the surface behind it. */
|
|
101
|
+
if (shownTool.value === 'pan') return
|
|
100
102
|
event.preventDefault()
|
|
101
103
|
event.stopPropagation()
|
|
102
104
|
const origin = measure()
|
|
@@ -217,6 +217,27 @@ code {
|
|
|
217
217
|
cursor: grabbing;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/* Inset, so the panels on either side never clip the ring. */
|
|
221
|
+
.surface:focus-visible {
|
|
222
|
+
outline-offset: -2px;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/*
|
|
226
|
+
* While panning, a frame takes no pointer, so a drag over one moves the view
|
|
227
|
+
* rather than picking an element, and the cursor reads the viewport's grab.
|
|
228
|
+
* The descendants are named too, since a selection handle sets its own
|
|
229
|
+
* pointer events and would otherwise take the drag as a resize. Nothing in
|
|
230
|
+
* the viewport starts a text selection while a pan drags across it.
|
|
231
|
+
*/
|
|
232
|
+
.surface[data-tool='pan'] .viewport {
|
|
233
|
+
user-select: none;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.surface[data-tool='pan'] .frame,
|
|
237
|
+
.surface[data-tool='pan'] .frame * {
|
|
238
|
+
pointer-events: none;
|
|
239
|
+
}
|
|
240
|
+
|
|
220
241
|
.plane {
|
|
221
242
|
position: absolute;
|
|
222
243
|
top: 0;
|
|
@@ -239,8 +260,9 @@ code {
|
|
|
239
260
|
align-items: center;
|
|
240
261
|
/*
|
|
241
262
|
* Sized against the zoom, so the label reads at one screen size at any zoom.
|
|
242
|
-
* A row too short for
|
|
243
|
-
* size onto lines the clip hides, so the name keeps the row
|
|
263
|
+
* A row too short for every part wraps the theme switch, then the editing
|
|
264
|
+
* badge, then the size onto lines the clip hides, so the name keeps the row
|
|
265
|
+
* longest.
|
|
244
266
|
*/
|
|
245
267
|
flex-wrap: wrap;
|
|
246
268
|
align-content: flex-start;
|
|
@@ -273,6 +295,40 @@ code {
|
|
|
273
295
|
font-variant-numeric: tabular-nums;
|
|
274
296
|
}
|
|
275
297
|
|
|
298
|
+
/*
|
|
299
|
+
* Ahead of the theme switch, so a row too short for both wraps the switch
|
|
300
|
+
* first. The editing outline still marks the frame when the badge wraps too.
|
|
301
|
+
*/
|
|
302
|
+
.frame-editing {
|
|
303
|
+
display: inline-flex;
|
|
304
|
+
align-items: center;
|
|
305
|
+
gap: calc(4px * var(--outline-scale, 1));
|
|
306
|
+
padding: calc(1px * var(--outline-scale, 1))
|
|
307
|
+
calc(6px * var(--outline-scale, 1));
|
|
308
|
+
border-radius: var(--radius-pill);
|
|
309
|
+
background: color-mix(in srgb, var(--color-success) 16%, transparent);
|
|
310
|
+
color: var(--color-success);
|
|
311
|
+
font-weight: 600;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.frame-editing:empty {
|
|
315
|
+
display: none;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.frame-editing::before {
|
|
319
|
+
content: '';
|
|
320
|
+
width: calc(6px * var(--outline-scale, 1));
|
|
321
|
+
height: calc(6px * var(--outline-scale, 1));
|
|
322
|
+
border-radius: 50%;
|
|
323
|
+
background: currentColor;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* Dashed, so it reads apart from the solid accent of a selection. */
|
|
327
|
+
.frame[data-editing='true'] iframe {
|
|
328
|
+
outline: calc(2px * var(--outline-scale, 1)) dashed var(--color-success);
|
|
329
|
+
outline-offset: calc(2px * var(--outline-scale, 1));
|
|
330
|
+
}
|
|
331
|
+
|
|
276
332
|
.frame-theme {
|
|
277
333
|
margin-left: auto;
|
|
278
334
|
padding: calc(1px * var(--outline-scale, 1))
|
|
@@ -425,6 +481,56 @@ code {
|
|
|
425
481
|
background: var(--color-chrome);
|
|
426
482
|
}
|
|
427
483
|
|
|
484
|
+
.tools {
|
|
485
|
+
position: absolute;
|
|
486
|
+
top: var(--space-md);
|
|
487
|
+
left: var(--space-md);
|
|
488
|
+
display: flex;
|
|
489
|
+
flex-direction: column;
|
|
490
|
+
gap: 2px;
|
|
491
|
+
padding: var(--space-xs);
|
|
492
|
+
border: 1px solid var(--color-border);
|
|
493
|
+
border-radius: var(--radius-panel);
|
|
494
|
+
background: var(--color-chrome);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.tool-button {
|
|
498
|
+
display: grid;
|
|
499
|
+
place-items: center;
|
|
500
|
+
width: 28px;
|
|
501
|
+
height: 28px;
|
|
502
|
+
padding: 0;
|
|
503
|
+
border: 0;
|
|
504
|
+
border-radius: var(--radius-action);
|
|
505
|
+
background: transparent;
|
|
506
|
+
color: var(--color-text-secondary);
|
|
507
|
+
cursor: pointer;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.tool-button svg {
|
|
511
|
+
width: 16px;
|
|
512
|
+
height: 16px;
|
|
513
|
+
fill: none;
|
|
514
|
+
stroke: currentColor;
|
|
515
|
+
stroke-width: 1.3;
|
|
516
|
+
stroke-linejoin: round;
|
|
517
|
+
stroke-linecap: round;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.tool-button[data-tool='move'] svg {
|
|
521
|
+
fill: currentColor;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.tool-button:hover {
|
|
525
|
+
color: var(--color-text);
|
|
526
|
+
background: var(--color-surface);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
.tool-button[aria-pressed='true'] {
|
|
530
|
+
color: var(--color-text);
|
|
531
|
+
background: var(--color-border);
|
|
532
|
+
}
|
|
533
|
+
|
|
428
534
|
.zoom {
|
|
429
535
|
min-width: 3.5rem;
|
|
430
536
|
text-align: center;
|