@erclx/canon 5.26.0 → 5.27.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 +2 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/SKILL.md +2 -0
- package/claude/skills/auto-ship/SKILL.md +3 -11
- package/claude/skills/auto-ship/references/review-findings.md +20 -0
- package/claude/skills/backlog-triage/SKILL.md +2 -0
- package/claude/skills/bash-cli-script/SKILL.md +2 -0
- package/claude/skills/build-in-slices/SKILL.md +2 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/canon-feedback/SKILL.md +2 -0
- package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
- package/claude/skills/canon-operator/SKILL.md +2 -0
- package/claude/skills/canon-rollout/SKILL.md +3 -11
- package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
- package/claude/skills/canvas/SKILL.md +13 -0
- package/claude/skills/ci-workflow/SKILL.md +2 -0
- package/claude/skills/code-craft/SKILL.md +2 -0
- package/claude/skills/codebase-layout/SKILL.md +2 -0
- package/claude/skills/context-fold/SKILL.md +3 -17
- package/claude/skills/context-fold/references/completion-report.md +26 -0
- package/claude/skills/create-rule/SKILL.md +2 -0
- package/claude/skills/create-skill/SKILL.md +2 -0
- package/claude/skills/create-standard/SKILL.md +2 -0
- package/claude/skills/decision-escalate/SKILL.md +2 -0
- package/claude/skills/deploy-app/SKILL.md +2 -0
- package/claude/skills/deprecation-migration/SKILL.md +2 -0
- package/claude/skills/design-extract/SKILL.md +2 -0
- package/claude/skills/design-taste/SKILL.md +2 -0
- package/claude/skills/docs-sync/SKILL.md +2 -0
- package/claude/skills/document-health/SKILL.md +2 -0
- package/claude/skills/draft-and-pick/SKILL.md +2 -0
- package/claude/skills/draft-doc/SKILL.md +2 -0
- package/claude/skills/draft-doc/references/wiki.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +2 -0
- package/claude/skills/draft-identity/SKILL.md +2 -0
- package/claude/skills/draft-ready/SKILL.md +2 -0
- package/claude/skills/draft-screencast/SKILL.md +2 -0
- package/claude/skills/draft-slides/SKILL.md +2 -0
- package/claude/skills/git-branch/SKILL.md +2 -0
- package/claude/skills/git-commit/SKILL.md +2 -0
- package/claude/skills/git-followup/SKILL.md +2 -0
- package/claude/skills/git-issue/SKILL.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/evidence.md +6 -6
- package/claude/skills/git-ship/SKILL.md +2 -0
- package/claude/skills/git-split/SKILL.md +2 -0
- package/claude/skills/git-stage/SKILL.md +2 -0
- package/claude/skills/git-worktree/SKILL.md +2 -0
- package/claude/skills/index-lookup/SKILL.md +2 -0
- package/claude/skills/markdown-propose/SKILL.md +2 -0
- package/claude/skills/memory-capture/SKILL.md +2 -0
- package/claude/skills/memory-review/SKILL.md +2 -0
- package/claude/skills/plan-feature/SKILL.md +2 -0
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/plan-intake/SKILL.md +2 -0
- package/claude/skills/plan-intake-answer/SKILL.md +2 -0
- package/claude/skills/project-commands/SKILL.md +2 -0
- package/claude/skills/read-frames/SKILL.md +2 -0
- package/claude/skills/record-screencast/SKILL.md +2 -0
- package/claude/skills/repo-metadata/SKILL.md +2 -0
- package/claude/skills/restate-plainly/SKILL.md +2 -0
- package/claude/skills/review-address/SKILL.md +3 -7
- package/claude/skills/review-address/references/post-review-findings.md +16 -0
- package/claude/skills/review-branch/SKILL.md +2 -0
- package/claude/skills/review-craft/SKILL.md +2 -0
- package/claude/skills/review-craft/references/ui.md +2 -2
- package/claude/skills/review-pr/SKILL.md +4 -8
- package/claude/skills/review-pr/references/body-variants.md +52 -0
- package/claude/skills/review-pr/references/key-changes.md +16 -0
- package/claude/skills/review-pr/references/post.md +15 -43
- package/claude/skills/review-ui/SKILL.md +3 -1
- package/claude/skills/role-orchestrator/SKILL.md +5 -14
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
- package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
- package/claude/skills/role-planner/SKILL.md +2 -0
- package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +2 -0
- package/claude/skills/role-worker/SKILL.md +2 -0
- package/claude/skills/search-craft/SKILL.md +2 -0
- package/claude/skills/seed-sync/SKILL.md +2 -0
- package/claude/skills/session-compact/SKILL.md +2 -0
- package/claude/skills/session-map/SKILL.md +2 -0
- package/claude/skills/session-relay/SKILL.md +2 -0
- package/claude/skills/session-resume/SKILL.md +2 -0
- package/claude/skills/session-worktree/SKILL.md +3 -11
- package/claude/skills/session-worktree/references/install-command.md +20 -0
- package/claude/skills/sketch-design/SKILL.md +2 -0
- package/claude/skills/systematic-debugging/SKILL.md +2 -0
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-setup/SKILL.md +2 -0
- package/claude/skills/task-board/SKILL.md +2 -0
- package/claude/skills/teach-workspace/SKILL.md +5 -15
- package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
- package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
- package/claude/skills/teach-workspace/references/revisit.md +22 -0
- package/claude/skills/test-craft/SKILL.md +2 -0
- package/claude/skills/test-first/SKILL.md +2 -0
- package/claude/skills/ui-checklist/SKILL.md +2 -0
- package/claude/skills/ux-audit/SKILL.md +2 -0
- package/claude/skills/ux-measure/SKILL.md +2 -0
- package/claude/skills/ux-walkthrough/SKILL.md +2 -0
- package/claude/skills/video-craft/SKILL.md +2 -0
- package/claude/skills/write-human/SKILL.md +2 -0
- package/claude/skills/youtube-transcripts/SKILL.md +2 -0
- package/docs/agents/canvas.md +23 -7
- package/docs/agents/capture.md +5 -5
- package/docs/agents/commands.md +2 -2
- package/docs/agents/index.md +3 -3
- package/docs/agents/install-and-sync.md +7 -0
- package/docs/agents/key-changes.md +1 -1
- package/docs/agents/pr-evidence.md +38 -15
- package/docs/agents/pr-local.md +19 -13
- package/docs/agents/pr-preview.md +44 -21
- package/docs/agents/sandbox.md +28 -4
- package/docs/agents/scripting.md +8 -1
- package/docs/agents/skills-audit.md +2 -1
- package/docs/workflow/operating-model.md +5 -6
- package/docs/workflow/parallel-features.md +22 -1
- package/docs/workflow/skill-map.md +3 -1
- package/docs/workflow/zshrc-aliases.md +1 -1
- package/package.json +1 -1
- package/scripts/core/regen-hero.ts +1 -1
- package/scripts/lib/sandbox-path.sh +6 -162
- package/src/audits/run.ts +1 -1
- package/src/canvas/address.ts +12 -3
- package/src/canvas/client/inspector/align-grid.tsx +162 -0
- package/src/canvas/client/inspector/color-field.tsx +201 -171
- package/src/canvas/client/inspector/color-inputs.tsx +139 -0
- package/src/canvas/client/inspector/color-picker.tsx +668 -0
- package/src/canvas/client/inspector/color-space.ts +240 -0
- package/src/canvas/client/inspector/field.tsx +5 -1
- package/src/canvas/client/inspector/fields.css +395 -6
- package/src/canvas/client/inspector/size-field.tsx +69 -0
- package/src/canvas/client/inspector/values.ts +43 -1
- package/src/canvas/client/inspector.tsx +190 -4
- package/src/canvas/client/selection.tsx +197 -0
- package/src/canvas/client/shell.css +104 -12
- package/src/canvas/client/state.ts +69 -9
- package/src/canvas/client/surface.tsx +147 -22
- package/src/canvas/content.ts +50 -1
- package/src/canvas/edit.ts +37 -4
- package/src/canvas/editing.ts +203 -0
- package/src/canvas/global.d.ts +15 -0
- package/src/canvas/server.ts +149 -12
- package/src/canvas/tokens.ts +59 -17
- package/src/census/count.ts +1 -1
- package/src/claude/seeds.ts +1 -1
- package/src/claude/skills-audit.ts +28 -0
- package/src/claude/skills-drift.ts +1 -1
- package/src/claude/skills-families.ts +36 -0
- package/src/claude/skills-list.ts +76 -15
- package/src/commands/audits.ts +2 -2
- package/src/commands/canvas.ts +179 -2
- package/src/commands/claude.ts +67 -9
- package/src/commands/design.ts +142 -2
- package/src/commands/gov.ts +1 -1
- package/src/commands/hooks.ts +1 -1
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +1 -1
- package/src/commands/intake.ts +1 -1
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pr.ts +105 -58
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +54 -2
- package/src/commands/sessions.ts +1 -1
- package/src/commands/sync.ts +1 -1
- package/src/commands/tasks.ts +2 -2
- package/src/commands/teach.ts +1 -1
- package/src/commands/transcripts.ts +1 -1
- package/src/commands/worktrees.ts +1 -1
- package/src/comments/scan.ts +1 -1
- package/src/comments/trend.ts +1 -1
- package/src/context/citations.ts +1 -1
- package/src/context/classify/extract.ts +2 -2
- package/src/design/project-fonts.ts +392 -0
- package/src/feedback/github.ts +1 -1
- package/src/gate/measures.ts +47 -9
- package/src/gate/sequencer.ts +1 -1
- package/src/gate/stages.ts +9 -0
- package/src/{git-files.ts → git/files.ts} +2 -2
- package/src/{git-ignore.ts → git/ignore.ts} +1 -1
- package/src/{worktree.ts → git/worktree.ts} +1 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/install.ts +1 -1
- package/src/gov/superseded.ts +1 -1
- package/src/gov/test-order.ts +2 -2
- package/src/help.ts +1 -1
- package/src/hooks/post-merge.ts +1 -1
- package/src/indexes/walk.ts +1 -1
- package/src/labels/audit.ts +1 -1
- package/src/markdown/files.ts +1 -1
- package/src/migrate/apply.ts +1 -1
- package/src/pr/bijection.ts +1 -1
- package/src/pr/evidence.ts +96 -43
- package/src/pr/local.ts +16 -10
- package/src/pr/preview.ts +78 -2
- package/src/records/backup.ts +1 -1
- package/src/records/stale-canonical.ts +1 -1
- package/src/sandbox/headless/dispatch.ts +264 -0
- package/src/sandbox/headless/run.ts +653 -0
- package/src/sandbox/headless/watch.ts +145 -0
- package/src/sandbox/provision.ts +12 -1
- package/src/sandbox/tree.ts +4 -8
- package/src/secrets/scan.ts +1 -1
- package/src/sessions/claim.ts +1 -1
- package/src/sessions/resolve.ts +1 -1
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/slides/package/fonts.ts +6 -2
- package/src/standards/audit.ts +2 -2
- package/src/sync/check.ts +2 -2
- package/src/sync/engine.ts +2 -2
- package/src/sync/git.ts +1 -1
- package/src/sync/history.ts +1 -1
- package/src/sync/reverse.ts +1 -1
- package/src/sync/target.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/pulls.ts +2 -2
- package/src/targets/sweep.ts +2 -2
- package/src/tasks/reach.ts +1 -1
- package/src/tasks/trunk.ts +1 -1
- package/src/tooling/inject.ts +1 -1
- package/src/tooling/subfolder.ts +1 -1
- package/src/web/readme-citations.ts +1 -1
- package/src/worktrees/bare-flag.ts +1 -1
- package/src/worktrees/reclaim.ts +2 -2
- package/src/worktrees/remove.ts +1 -1
- package/standards/canvas.md +2 -1
- package/standards/skill.md +1 -0
- package/standards/wiki.md +12 -3
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
- package/scripts/lib/sandbox-dispatch.sh +0 -190
- /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
- /package/src/{git-base.ts → git/base.ts} +0 -0
- /package/src/{git-env.ts → git/env.ts} +0 -0
- /package/src/{copy.ts → sync/copy.ts} +0 -0
- /package/src/{target.ts → targets/validate.ts} +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX } from 'preact'
|
|
3
|
+
import { Field, type Scrub } from '@/canvas/client/inspector/field'
|
|
4
|
+
|
|
5
|
+
/** The modes a size takes besides a number, by the name the field shows. */
|
|
6
|
+
const MODES = ['Fill', 'Fit'] as const
|
|
7
|
+
|
|
8
|
+
interface SizeFieldProps {
|
|
9
|
+
readonly label: string
|
|
10
|
+
readonly glyph: string
|
|
11
|
+
/** The value as the panel shows it, a mode name or a number. */
|
|
12
|
+
readonly initial: string
|
|
13
|
+
readonly isBusy: boolean
|
|
14
|
+
/** Takes a typed number or a picked mode name, each as shown. */
|
|
15
|
+
readonly onCommit: (value: string) => void
|
|
16
|
+
readonly scrub?: Scrub
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A size that takes a number or a mode. The chevron is a native select, so
|
|
21
|
+
* its list opens above the panel's clipping and answers the keyboard as the
|
|
22
|
+
* platform does, and typing a number replaces the mode.
|
|
23
|
+
*/
|
|
24
|
+
export function SizeField({
|
|
25
|
+
label,
|
|
26
|
+
glyph,
|
|
27
|
+
initial,
|
|
28
|
+
isBusy,
|
|
29
|
+
onCommit,
|
|
30
|
+
scrub,
|
|
31
|
+
}: SizeFieldProps): JSX.Element {
|
|
32
|
+
const mode = (MODES as readonly string[]).includes(initial) ? initial : ''
|
|
33
|
+
return (
|
|
34
|
+
<Field
|
|
35
|
+
label={label}
|
|
36
|
+
glyph={glyph}
|
|
37
|
+
initial={initial}
|
|
38
|
+
isBusy={isBusy}
|
|
39
|
+
onCommit={onCommit}
|
|
40
|
+
scrub={scrub}
|
|
41
|
+
trailing={
|
|
42
|
+
<span class="size-mode">
|
|
43
|
+
<select
|
|
44
|
+
class="size-mode-select"
|
|
45
|
+
aria-label={`${label} mode`}
|
|
46
|
+
value={mode}
|
|
47
|
+
disabled={isBusy}
|
|
48
|
+
onChange={(event) => {
|
|
49
|
+
const picked = event.currentTarget.value
|
|
50
|
+
if (picked && picked !== mode) onCommit(picked)
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<option value="" disabled hidden>
|
|
54
|
+
Fixed
|
|
55
|
+
</option>
|
|
56
|
+
{MODES.map((name) => (
|
|
57
|
+
<option key={name} value={name}>
|
|
58
|
+
{name}
|
|
59
|
+
</option>
|
|
60
|
+
))}
|
|
61
|
+
</select>
|
|
62
|
+
<svg class="size-mode-chevron" viewBox="0 0 16 16" aria-hidden="true">
|
|
63
|
+
<path d="M4.5 6.5 8 10l3.5-3.5" />
|
|
64
|
+
</svg>
|
|
65
|
+
</span>
|
|
66
|
+
}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -5,7 +5,25 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/** Properties whose bare numbers are pixel lengths. */
|
|
8
|
-
const LENGTHS = new Set([
|
|
8
|
+
const LENGTHS = new Set([
|
|
9
|
+
'width',
|
|
10
|
+
'height',
|
|
11
|
+
'padding',
|
|
12
|
+
'gap',
|
|
13
|
+
'font-size',
|
|
14
|
+
'border-radius',
|
|
15
|
+
'border-top-left-radius',
|
|
16
|
+
'border-top-right-radius',
|
|
17
|
+
'border-bottom-right-radius',
|
|
18
|
+
'border-bottom-left-radius',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
/** Properties a size mode writes, each mode by the name the field shows. */
|
|
22
|
+
const SIZES = new Set(['width', 'height'])
|
|
23
|
+
const SIZE_MODES: Readonly<Record<string, string>> = {
|
|
24
|
+
Fill: '100%',
|
|
25
|
+
Fit: 'fit-content',
|
|
26
|
+
}
|
|
9
27
|
|
|
10
28
|
const PX = /^(-?\d*\.?\d+)px$/
|
|
11
29
|
|
|
@@ -28,6 +46,14 @@ export function displayValue(property: string, raw: string): string {
|
|
|
28
46
|
const value = raw.trim()
|
|
29
47
|
if (property === 'gap' && (value === '' || value === 'normal')) return ''
|
|
30
48
|
if (property === 'background-color' && isNoFill(value)) return ''
|
|
49
|
+
if (property === 'opacity' && value !== '')
|
|
50
|
+
return `${Math.round(Number(value) * 100)}%`
|
|
51
|
+
if (SIZES.has(property)) {
|
|
52
|
+
const mode = Object.keys(SIZE_MODES).find(
|
|
53
|
+
(name) => SIZE_MODES[name] === value,
|
|
54
|
+
)
|
|
55
|
+
if (mode) return mode
|
|
56
|
+
}
|
|
31
57
|
if (value === 'auto') return 'Auto'
|
|
32
58
|
if (value === 'normal') return 'Normal'
|
|
33
59
|
return value.split(/\s+/).map(formatPx).join(' ')
|
|
@@ -36,6 +62,7 @@ export function displayValue(property: string, raw: string): string {
|
|
|
36
62
|
export function toCssValue(property: string, typed: string): string {
|
|
37
63
|
const value = typed.trim()
|
|
38
64
|
if (value === 'Auto' || value === 'Normal') return value.toLowerCase()
|
|
65
|
+
if (SIZES.has(property) && SIZE_MODES[value]) return SIZE_MODES[value]
|
|
39
66
|
if (!LENGTHS.has(property)) return value
|
|
40
67
|
return value
|
|
41
68
|
.split(/\s+/)
|
|
@@ -43,6 +70,21 @@ export function toCssValue(property: string, typed: string): string {
|
|
|
43
70
|
.join(' ')
|
|
44
71
|
}
|
|
45
72
|
|
|
73
|
+
/** A typed opacity percent as the fraction CSS takes, refused outside 0 to 100. */
|
|
74
|
+
export function opacityToCss(typed: string): string | undefined {
|
|
75
|
+
const percent = readOpacity(typed)
|
|
76
|
+
return percent === undefined ? undefined : String(percent / 100)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The radius all four corners share, read in the order the longhands run,
|
|
81
|
+
* or undefined where any corner differs and the panel shows each one.
|
|
82
|
+
*/
|
|
83
|
+
export function sharedRadius(corners: readonly string[]): string | undefined {
|
|
84
|
+
const [first] = corners
|
|
85
|
+
return corners.every((corner) => corner === first) ? first : undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
46
88
|
/**
|
|
47
89
|
* Holds a scrubbed number inside what the property accepts. The writer takes
|
|
48
90
|
* any declaration that does not break the rule, so a negative length or a
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** @jsxImportSource preact */
|
|
2
2
|
import type { JSX } from 'preact'
|
|
3
|
+
import { useState } from 'preact/hooks'
|
|
3
4
|
import { addressOf, elementAt, excerpt, isRawText } from '@/canvas/address'
|
|
5
|
+
import { AlignGrid } from '@/canvas/client/inspector/align-grid'
|
|
4
6
|
import { ColorField } from '@/canvas/client/inspector/color-field'
|
|
5
7
|
import {
|
|
6
8
|
Field,
|
|
@@ -8,10 +10,13 @@ import {
|
|
|
8
10
|
type Scrub,
|
|
9
11
|
} from '@/canvas/client/inspector/field'
|
|
10
12
|
import { Section } from '@/canvas/client/inspector/section'
|
|
13
|
+
import { SizeField } from '@/canvas/client/inspector/size-field'
|
|
11
14
|
import {
|
|
12
15
|
clampScrub,
|
|
13
16
|
displayValue,
|
|
17
|
+
opacityToCss,
|
|
14
18
|
parseColor,
|
|
19
|
+
sharedRadius,
|
|
15
20
|
toCssValue,
|
|
16
21
|
} from '@/canvas/client/inspector/values'
|
|
17
22
|
import {
|
|
@@ -66,12 +71,47 @@ const SIZE_FIELDS: readonly StyleField[] = [
|
|
|
66
71
|
{ label: 'height', glyph: 'H', property: 'height' },
|
|
67
72
|
]
|
|
68
73
|
|
|
74
|
+
const DIRECTION_FIELD: StyleField = {
|
|
75
|
+
label: 'direction',
|
|
76
|
+
glyph: 'Dir',
|
|
77
|
+
property: 'flex-direction',
|
|
78
|
+
}
|
|
79
|
+
|
|
69
80
|
const FLEX_FIELDS: readonly StyleField[] = [
|
|
70
|
-
{ label: 'direction', glyph: 'Dir', property: 'flex-direction' },
|
|
71
81
|
{ label: 'gap', glyph: 'Gap', property: 'gap', placeholder: '0' },
|
|
72
|
-
{ label: 'padding', glyph: 'Pad', property: 'padding', isWide: true },
|
|
73
82
|
]
|
|
74
83
|
|
|
84
|
+
/** Padding applies to any box, so it sits in Layout rather than Flex. */
|
|
85
|
+
const PADDING_FIELD: StyleField = {
|
|
86
|
+
label: 'padding',
|
|
87
|
+
glyph: 'Pad',
|
|
88
|
+
property: 'padding',
|
|
89
|
+
isWide: true,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The corners in the order the radius shorthand lists them. */
|
|
93
|
+
const CORNER_FIELDS: readonly StyleField[] = [
|
|
94
|
+
{ label: 'top left radius', glyph: 'TL', property: 'border-top-left-radius' },
|
|
95
|
+
{
|
|
96
|
+
label: 'top right radius',
|
|
97
|
+
glyph: 'TR',
|
|
98
|
+
property: 'border-top-right-radius',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
label: 'bottom right radius',
|
|
102
|
+
glyph: 'BR',
|
|
103
|
+
property: 'border-bottom-right-radius',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
label: 'bottom left radius',
|
|
107
|
+
glyph: 'BL',
|
|
108
|
+
property: 'border-bottom-left-radius',
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
const FLEX_DISPLAYS = new Set(['flex', 'inline-flex'])
|
|
113
|
+
const GRID_DISPLAYS = new Set(['grid', 'inline-grid'])
|
|
114
|
+
|
|
75
115
|
const TYPE_FIELDS: readonly StyleField[] = [
|
|
76
116
|
{ label: 'size', glyph: 'Size', property: 'font-size' },
|
|
77
117
|
{ label: 'weight', glyph: 'Wt', property: 'font-weight' },
|
|
@@ -175,6 +215,31 @@ function ElementFields({
|
|
|
175
215
|
const commit = (property: string) => (value: string) => {
|
|
176
216
|
if (address) void editElement(frameRef, key, address, property, value)
|
|
177
217
|
}
|
|
218
|
+
/*
|
|
219
|
+
* The writer takes one property an edit, so a control setting two posts
|
|
220
|
+
* them in turn and stops at the first refusal. Each carries the hash the
|
|
221
|
+
* last answered with in its own address, since a frame reloading between
|
|
222
|
+
* the two clears the stored one.
|
|
223
|
+
*/
|
|
224
|
+
const commitAll = async (
|
|
225
|
+
changes: readonly (readonly [string, string])[],
|
|
226
|
+
): Promise<void> => {
|
|
227
|
+
let current = address
|
|
228
|
+
for (const [property, value] of changes) {
|
|
229
|
+
if (!current) return
|
|
230
|
+
const written = await editElement(frameRef, key, current, property, value)
|
|
231
|
+
if (!written) return
|
|
232
|
+
if (written.hash) current = { ...current, hash: written.hash }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const corners = CORNER_FIELDS.map((field) =>
|
|
236
|
+
currentValue(node, field.property).trim(),
|
|
237
|
+
)
|
|
238
|
+
const radius = sharedRadius(corners)
|
|
239
|
+
const [isPerCorner, setPerCorner] = useState(radius === undefined)
|
|
240
|
+
const display = computedValue(node, 'display').trim()
|
|
241
|
+
const isFlex = FLEX_DISPLAYS.has(display)
|
|
242
|
+
const isWrapped = computedValue(node, 'flex-wrap').trim() === 'wrap'
|
|
178
243
|
const rect = node.getBoundingClientRect()
|
|
179
244
|
const font = currentValue(node, 'font-family')
|
|
180
245
|
const text = node.textContent ?? ''
|
|
@@ -237,9 +302,125 @@ function ElementFields({
|
|
|
237
302
|
glyph="Y"
|
|
238
303
|
value={String(Math.round(rect.top))}
|
|
239
304
|
/>
|
|
240
|
-
{SIZE_FIELDS.map(
|
|
305
|
+
{SIZE_FIELDS.map((field) => (
|
|
306
|
+
<SizeField
|
|
307
|
+
key={field.property}
|
|
308
|
+
label={field.label}
|
|
309
|
+
glyph={field.glyph}
|
|
310
|
+
initial={displayValue(
|
|
311
|
+
field.property,
|
|
312
|
+
currentValue(node, field.property),
|
|
313
|
+
)}
|
|
314
|
+
isBusy={isBusy}
|
|
315
|
+
onCommit={(typed) =>
|
|
316
|
+
commit(field.property)(toCssValue(field.property, typed))
|
|
317
|
+
}
|
|
318
|
+
scrub={'style' in node ? scrubOf(field.property) : undefined}
|
|
319
|
+
/>
|
|
320
|
+
))}
|
|
321
|
+
{styleField(PADDING_FIELD)}
|
|
322
|
+
</Section>
|
|
323
|
+
<Section
|
|
324
|
+
title="Flex"
|
|
325
|
+
action={
|
|
326
|
+
isFlex || GRID_DISPLAYS.has(display) ? null : (
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
class="section-icon"
|
|
330
|
+
aria-label="Add flex layout"
|
|
331
|
+
title="Add flex layout"
|
|
332
|
+
disabled={isBusy}
|
|
333
|
+
onClick={() => commit('display')('flex')}
|
|
334
|
+
>
|
|
335
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
336
|
+
<path d="M8 3.5v9M3.5 8h9" />
|
|
337
|
+
</svg>
|
|
338
|
+
</button>
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
>
|
|
342
|
+
{isFlex ? (
|
|
343
|
+
<>
|
|
344
|
+
<div class="flex-layout is-wide">
|
|
345
|
+
<AlignGrid
|
|
346
|
+
direction={computedValue(node, 'flex-direction').trim()}
|
|
347
|
+
justifyContent={computedValue(node, 'justify-content')}
|
|
348
|
+
alignItems={computedValue(node, 'align-items')}
|
|
349
|
+
isBusy={isBusy}
|
|
350
|
+
onPick={(alignment) =>
|
|
351
|
+
void commitAll([
|
|
352
|
+
['justify-content', alignment.justifyContent],
|
|
353
|
+
['align-items', alignment.alignItems],
|
|
354
|
+
])
|
|
355
|
+
}
|
|
356
|
+
/>
|
|
357
|
+
<div class="flex-layout-side">
|
|
358
|
+
{styleField(DIRECTION_FIELD)}
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
class="section-icon"
|
|
362
|
+
aria-label="Wrap"
|
|
363
|
+
title="Wrap"
|
|
364
|
+
aria-pressed={isWrapped}
|
|
365
|
+
disabled={isBusy}
|
|
366
|
+
onClick={() =>
|
|
367
|
+
commit('flex-wrap')(isWrapped ? 'nowrap' : 'wrap')
|
|
368
|
+
}
|
|
369
|
+
>
|
|
370
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
371
|
+
<path d="M2.5 4.5h9a2.5 2.5 0 0 1 0 5h-6M7.5 7.5l-2 2 2 2M2.5 12.5h1" />
|
|
372
|
+
</svg>
|
|
373
|
+
</button>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
{FLEX_FIELDS.map(styleField)}
|
|
377
|
+
</>
|
|
378
|
+
) : null}
|
|
379
|
+
</Section>
|
|
380
|
+
<Section
|
|
381
|
+
title="Appearance"
|
|
382
|
+
action={
|
|
383
|
+
<button
|
|
384
|
+
type="button"
|
|
385
|
+
class="section-icon"
|
|
386
|
+
aria-label="Per-corner radius"
|
|
387
|
+
title="Per-corner radius"
|
|
388
|
+
aria-pressed={isPerCorner}
|
|
389
|
+
onClick={() => setPerCorner(!isPerCorner)}
|
|
390
|
+
>
|
|
391
|
+
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
392
|
+
<path d="M3 6.5V5a2 2 0 0 1 2-2h1.5M9.5 3H11a2 2 0 0 1 2 2v1.5M13 9.5V11a2 2 0 0 1-2 2H9.5M6.5 13H5a2 2 0 0 1-2-2V9.5" />
|
|
393
|
+
</svg>
|
|
394
|
+
</button>
|
|
395
|
+
}
|
|
396
|
+
>
|
|
397
|
+
<Field
|
|
398
|
+
label="opacity"
|
|
399
|
+
glyph="Op"
|
|
400
|
+
initial={displayValue('opacity', computedValue(node, 'opacity'))}
|
|
401
|
+
isBusy={isBusy}
|
|
402
|
+
isWide={isPerCorner}
|
|
403
|
+
onCommit={(typed) => {
|
|
404
|
+
const value = opacityToCss(typed)
|
|
405
|
+
if (value !== undefined) commit('opacity')(value)
|
|
406
|
+
}}
|
|
407
|
+
/>
|
|
408
|
+
{isPerCorner ? (
|
|
409
|
+
CORNER_FIELDS.map(styleField)
|
|
410
|
+
) : (
|
|
411
|
+
<Field
|
|
412
|
+
label="radius"
|
|
413
|
+
glyph="R"
|
|
414
|
+
initial={displayValue('border-radius', radius ?? '')}
|
|
415
|
+
isBusy={isBusy}
|
|
416
|
+
placeholder="0"
|
|
417
|
+
onCommit={(typed) =>
|
|
418
|
+
commit('border-radius')(toCssValue('border-radius', typed))
|
|
419
|
+
}
|
|
420
|
+
scrub={'style' in node ? scrubOf('border-radius') : undefined}
|
|
421
|
+
/>
|
|
422
|
+
)}
|
|
241
423
|
</Section>
|
|
242
|
-
<Section title="Flex">{FLEX_FIELDS.map(styleField)}</Section>
|
|
243
424
|
<Section title="Typography">
|
|
244
425
|
{TYPE_FIELDS.map(styleField)}
|
|
245
426
|
{font ? (
|
|
@@ -261,11 +442,16 @@ function ElementFields({
|
|
|
261
442
|
label={field.label}
|
|
262
443
|
color={parseColor(inline || painted)}
|
|
263
444
|
text={inline || painted}
|
|
445
|
+
inline={inline}
|
|
264
446
|
painted={painted}
|
|
447
|
+
owner={`${key}:${address?.index ?? -1}:${field.property}`}
|
|
265
448
|
tokens={colorTokens}
|
|
266
449
|
paint={(name) => tokenPaint(doc, name)}
|
|
267
450
|
isRaw={isRawInline(node, field.property)}
|
|
268
451
|
isBusy={isBusy}
|
|
452
|
+
preview={
|
|
453
|
+
'style' in node ? scrubOf(field.property).begin : undefined
|
|
454
|
+
}
|
|
269
455
|
onCommit={commit(field.property)}
|
|
270
456
|
/>
|
|
271
457
|
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
import type { JSX } from 'preact'
|
|
3
|
+
import { useRef, useState } from 'preact/hooks'
|
|
4
|
+
import { view } from '@/canvas/client/state'
|
|
5
|
+
|
|
6
|
+
/** Screen pixels a press travels before it reads as a drag and not a click. */
|
|
7
|
+
export const DRAG_THRESHOLD = 3
|
|
8
|
+
|
|
9
|
+
/** The smallest box a handle drag leaves, in surface units. */
|
|
10
|
+
const MIN_SIZE = 1
|
|
11
|
+
|
|
12
|
+
export type Corner = 'nw' | 'ne' | 'sw' | 'se'
|
|
13
|
+
|
|
14
|
+
const CORNERS: readonly Corner[] = ['nw', 'ne', 'sw', 'se']
|
|
15
|
+
|
|
16
|
+
export interface OverlayBox {
|
|
17
|
+
readonly x: number
|
|
18
|
+
readonly y: number
|
|
19
|
+
readonly width: number
|
|
20
|
+
readonly height: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The box a corner drag leaves, with the delta already in surface units. The
|
|
25
|
+
* corner opposite the one dragged stays put, so a west or north handle moves
|
|
26
|
+
* the box as it resizes it.
|
|
27
|
+
*/
|
|
28
|
+
export function resizedBox(
|
|
29
|
+
origin: OverlayBox,
|
|
30
|
+
corner: Corner,
|
|
31
|
+
dx: number,
|
|
32
|
+
dy: number,
|
|
33
|
+
): OverlayBox {
|
|
34
|
+
const isWest = corner === 'nw' || corner === 'sw'
|
|
35
|
+
const isNorth = corner === 'nw' || corner === 'ne'
|
|
36
|
+
const width = Math.max(MIN_SIZE, origin.width + (isWest ? -dx : dx))
|
|
37
|
+
const height = Math.max(MIN_SIZE, origin.height + (isNorth ? -dy : dy))
|
|
38
|
+
return {
|
|
39
|
+
x: isWest ? origin.x + origin.width - width : origin.x,
|
|
40
|
+
y: isNorth ? origin.y + origin.height - height : origin.y,
|
|
41
|
+
width,
|
|
42
|
+
height,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function sizeLabel(box: Pick<OverlayBox, 'width' | 'height'>): string {
|
|
47
|
+
return `${Math.round(box.width)} × ${Math.round(box.height)}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface HandleGesture {
|
|
51
|
+
readonly corner: Corner
|
|
52
|
+
readonly startX: number
|
|
53
|
+
readonly startY: number
|
|
54
|
+
readonly origin: OverlayBox
|
|
55
|
+
box: OverlayBox
|
|
56
|
+
moved: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SelectionOverlayProps {
|
|
60
|
+
readonly kind: 'frame' | 'element'
|
|
61
|
+
/** Read on every render, so a box the drag previewed is what shows after. */
|
|
62
|
+
readonly measure: () => OverlayBox
|
|
63
|
+
/** Subtracted from the box to place the overlay inside its container. */
|
|
64
|
+
readonly offset?: { readonly x: number; readonly y: number }
|
|
65
|
+
readonly hasChip?: boolean
|
|
66
|
+
/**
|
|
67
|
+
* Draws the target's own measured box during a drag rather than the dragged
|
|
68
|
+
* one. An element in normal flow grows from its own top left whichever corner
|
|
69
|
+
* is dragged, so only its measured box shows where it actually sits.
|
|
70
|
+
*/
|
|
71
|
+
readonly isMeasuredWhileDragging?: boolean
|
|
72
|
+
readonly onStart?: () => void
|
|
73
|
+
readonly onPreview?: (box: OverlayBox, origin: OverlayBox) => void
|
|
74
|
+
readonly onCommit: (box: OverlayBox, origin: OverlayBox) => void
|
|
75
|
+
readonly onCancel?: (origin: OverlayBox) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The selected frame's or element's outline, its four corner handles, and an
|
|
80
|
+
* element's size chip. Every part sizes against `--outline-scale`, so it keeps
|
|
81
|
+
* one screen weight at any zoom.
|
|
82
|
+
*/
|
|
83
|
+
export function SelectionOverlay({
|
|
84
|
+
kind,
|
|
85
|
+
measure,
|
|
86
|
+
offset = { x: 0, y: 0 },
|
|
87
|
+
hasChip = false,
|
|
88
|
+
isMeasuredWhileDragging = false,
|
|
89
|
+
onStart,
|
|
90
|
+
onPreview,
|
|
91
|
+
onCommit,
|
|
92
|
+
onCancel,
|
|
93
|
+
}: SelectionOverlayProps): JSX.Element {
|
|
94
|
+
const gesture = useRef<HandleGesture | undefined>(undefined)
|
|
95
|
+
const [dragged, setDragged] = useState<OverlayBox | undefined>(undefined)
|
|
96
|
+
const box = dragged ?? measure()
|
|
97
|
+
|
|
98
|
+
const handlePointerDown = (corner: Corner) => (event: PointerEvent) => {
|
|
99
|
+
if (event.button !== 0) return
|
|
100
|
+
event.preventDefault()
|
|
101
|
+
event.stopPropagation()
|
|
102
|
+
const origin = measure()
|
|
103
|
+
gesture.current = {
|
|
104
|
+
corner,
|
|
105
|
+
startX: event.clientX,
|
|
106
|
+
startY: event.clientY,
|
|
107
|
+
origin,
|
|
108
|
+
box: origin,
|
|
109
|
+
moved: false,
|
|
110
|
+
}
|
|
111
|
+
;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
115
|
+
const current = gesture.current
|
|
116
|
+
if (!current) return
|
|
117
|
+
const dx = event.clientX - current.startX
|
|
118
|
+
const dy = event.clientY - current.startY
|
|
119
|
+
if (!current.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
|
|
120
|
+
if (!current.moved) onStart?.()
|
|
121
|
+
current.moved = true
|
|
122
|
+
/* The delta is in screen pixels and the box in surface units. */
|
|
123
|
+
const { zoom } = view.value
|
|
124
|
+
current.box = resizedBox(
|
|
125
|
+
current.origin,
|
|
126
|
+
current.corner,
|
|
127
|
+
Math.round(dx / zoom),
|
|
128
|
+
Math.round(dy / zoom),
|
|
129
|
+
)
|
|
130
|
+
onPreview?.(current.box, current.origin)
|
|
131
|
+
setDragged(isMeasuredWhileDragging ? measure() : current.box)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const handlePointerUp = () => {
|
|
135
|
+
const current = gesture.current
|
|
136
|
+
gesture.current = undefined
|
|
137
|
+
if (current?.moved) onCommit(current.box, current.origin)
|
|
138
|
+
setDragged(undefined)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const handlePointerCancel = () => {
|
|
142
|
+
const current = gesture.current
|
|
143
|
+
gesture.current = undefined
|
|
144
|
+
if (current?.moved) onCancel?.(current.origin)
|
|
145
|
+
setDragged(undefined)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div
|
|
150
|
+
class="selection"
|
|
151
|
+
data-selection={kind}
|
|
152
|
+
data-resizing={dragged ? 'true' : undefined}
|
|
153
|
+
style={{
|
|
154
|
+
left: `${box.x - offset.x}px`,
|
|
155
|
+
top: `${box.y - offset.y}px`,
|
|
156
|
+
width: `${box.width}px`,
|
|
157
|
+
height: `${box.height}px`,
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
{CORNERS.map((corner) => (
|
|
161
|
+
<span
|
|
162
|
+
key={corner}
|
|
163
|
+
class="selection-handle"
|
|
164
|
+
data-handle={corner}
|
|
165
|
+
aria-hidden="true"
|
|
166
|
+
onPointerDown={handlePointerDown(corner)}
|
|
167
|
+
onPointerMove={handlePointerMove}
|
|
168
|
+
onPointerUp={handlePointerUp}
|
|
169
|
+
onPointerCancel={handlePointerCancel}
|
|
170
|
+
/>
|
|
171
|
+
))}
|
|
172
|
+
{hasChip ? (
|
|
173
|
+
<span class="selection-chip" aria-hidden="true">
|
|
174
|
+
{sizeLabel(box)}
|
|
175
|
+
</span>
|
|
176
|
+
) : null}
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The element under the pointer, drawn without handles. */
|
|
182
|
+
export function HoverOutline({ element }: { readonly element: Element }) {
|
|
183
|
+
const rect = element.getBoundingClientRect()
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
class="element-outline"
|
|
187
|
+
data-outline="hover"
|
|
188
|
+
aria-hidden="true"
|
|
189
|
+
style={{
|
|
190
|
+
left: `${rect.left}px`,
|
|
191
|
+
top: `${rect.top}px`,
|
|
192
|
+
width: `${rect.width}px`,
|
|
193
|
+
height: `${rect.height}px`,
|
|
194
|
+
}}
|
|
195
|
+
/>
|
|
196
|
+
)
|
|
197
|
+
}
|