@erclx/canon 5.26.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/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 +5 -1
- 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 +7 -7
- 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/REQUIREMENT.md +4 -2
- package/claude/skills/review-address/SKILL.md +19 -14
- package/claude/skills/review-address/references/post-review-findings.md +16 -0
- package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
- 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 +6 -6
- 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/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +16 -12
- 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 +31 -42
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +13 -3
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -34
- 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 +12 -23
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
- 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-sweep.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
- 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 +4 -2
- package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +3 -1
- package/claude/skills/role-worker/SKILL.md +5 -2
- 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 +3 -1
- 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 +24 -8
- package/docs/agents/capture.md +5 -5
- package/docs/agents/commands.md +2 -2
- package/docs/agents/gate.md +3 -1
- package/docs/agents/index.md +5 -4
- package/docs/agents/install-and-sync.md +7 -0
- package/docs/agents/key-changes.md +1 -1
- package/docs/agents/pr-evidence.md +47 -23
- package/docs/agents/pr-local.md +19 -13
- package/docs/agents/pr-preview.md +44 -21
- package/docs/agents/pr-tick.md +72 -0
- package/docs/agents/sandbox.md +30 -6
- package/docs/agents/scripting.md +8 -1
- package/docs/agents/skills-audit.md +2 -1
- 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 +15 -21
- package/docs/workflow/parallel-features.md +24 -3
- 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/baseline.ts +1 -1
- package/src/audits/run.ts +2 -2
- package/src/canvas/address.ts +12 -3
- package/src/canvas/capture.ts +1 -1
- 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 +66 -3
- package/src/canvas/client/inspector/fields.css +473 -6
- package/src/canvas/client/inspector/size-field.tsx +69 -0
- package/src/canvas/client/inspector/token-select.tsx +109 -0
- package/src/canvas/client/inspector/values.ts +117 -3
- package/src/canvas/client/inspector.tsx +311 -15
- package/src/canvas/client/keys.ts +54 -0
- package/src/canvas/client/selection.tsx +199 -0
- package/src/canvas/client/shell.css +210 -12
- package/src/canvas/client/state.ts +165 -14
- package/src/canvas/client/surface.tsx +341 -39
- package/src/canvas/client/tools.tsx +64 -0
- package/src/canvas/content.ts +51 -2
- package/src/canvas/edit.ts +49 -6
- 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 +60 -18
- package/src/census/count.ts +1 -1
- package/src/claude/seeds.ts +3 -3
- 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/claude/skills-reach.ts +1 -1
- package/src/commands/audits.ts +2 -2
- package/src/commands/canvas.ts +180 -3
- package/src/commands/claude.ts +70 -12
- package/src/commands/design.ts +145 -5
- 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 +3 -3
- package/src/commands/hooks.ts +1 -1
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +2 -2
- package/src/commands/intake.ts +1 -1
- package/src/commands/migrate.ts +2 -2
- package/src/commands/pr.ts +288 -65
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +64 -15
- package/src/commands/sessions.ts +1 -1
- package/src/commands/slides.ts +1 -1
- package/src/commands/sync.ts +2 -2
- package/src/commands/tasks.ts +21 -4
- package/src/commands/teach.ts +1 -1
- package/src/commands/tooling.ts +1 -1
- package/src/commands/transcripts.ts +2 -2
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/wiki.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/architecture.ts +1 -1
- package/src/context/citations.ts +3 -3
- package/src/context/classify/extract.ts +3 -3
- package/src/context/folders.ts +2 -2
- package/src/context/requirements.ts +1 -1
- package/src/design/project-fonts.ts +392 -0
- package/src/docs/read.ts +1 -1
- package/src/exec.ts +1 -1
- package/src/feedback/github.ts +2 -2
- package/src/gate/measures.ts +48 -10
- package/src/gate/sequencer.ts +56 -4
- package/src/gate/stages.ts +17 -0
- package/src/gate/suite-lock.ts +214 -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/intake/folder.ts +1 -1
- package/src/labels/audit.ts +1 -1
- package/src/labels/map.ts +1 -1
- package/src/labels/phase.ts +3 -3
- package/src/markdown/files.ts +1 -1
- package/src/migrate/apply.ts +1 -1
- 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/bijection.ts +1 -1
- package/src/pr/evidence.ts +250 -59
- package/src/pr/local.ts +16 -10
- package/src/pr/preview.ts +78 -2
- package/src/pr/tick.ts +56 -0
- package/src/process/harness.ts +1 -1
- package/src/records/backup.ts +2 -2
- 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 +2 -2
- 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/headless/dispatch.ts +264 -0
- package/src/sandbox/headless/run.ts +653 -0
- package/src/sandbox/headless/watch.ts +145 -0
- package/src/sandbox/hooks.ts +1 -1
- package/src/sandbox/provision.ts +90 -29
- package/src/sandbox/scenario.ts +588 -0
- package/src/sandbox/tree.ts +4 -8
- package/src/secrets/scan.ts +1 -1
- package/src/sessions/claim.ts +1 -1
- package/src/sessions/live.ts +4 -1
- package/src/sessions/resolve.ts +1 -1
- package/src/sessions/transfer/bundle.ts +2 -2
- package/src/slides/decks.ts +1 -1
- package/src/slides/package/fonts.ts +6 -2
- package/src/standards/audit.ts +2 -2
- package/src/standards/read.ts +1 -1
- package/src/sync/check.ts +2 -2
- package/src/sync/engine.ts +3 -3
- package/src/sync/git.ts +1 -1
- package/src/sync/history.ts +1 -1
- package/src/sync/layout.ts +2 -2
- package/src/sync/reverse.ts +1 -1
- package/src/sync/stamp.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/{target.ts → 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 +2 -2
- package/src/tasks/trunk.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/subfolder.ts +1 -1
- package/src/version/installed.ts +1 -1
- package/src/version/skew.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/board.md +3 -3
- package/standards/canvas.md +2 -1
- package/standards/plan.md +13 -7
- 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/tsconfig.json +1 -1
- 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/{legacy-path.ts → roots/legacy.ts} +0 -0
- /package/src/{surface-root.ts → roots/surface.ts} +0 -0
- /package/src/{copy.ts → sync/copy.ts} +0 -0
package/src/canvas/edit.ts
CHANGED
|
@@ -27,15 +27,46 @@ export const STYLE_PROPERTIES = [
|
|
|
27
27
|
'background-color',
|
|
28
28
|
'font-size',
|
|
29
29
|
'font-weight',
|
|
30
|
+
'font-family',
|
|
31
|
+
'line-height',
|
|
32
|
+
'letter-spacing',
|
|
33
|
+
'text-align',
|
|
30
34
|
'width',
|
|
31
35
|
'height',
|
|
32
36
|
'padding',
|
|
33
37
|
'gap',
|
|
38
|
+
'display',
|
|
34
39
|
'flex-direction',
|
|
40
|
+
'justify-content',
|
|
41
|
+
'align-items',
|
|
42
|
+
'flex-wrap',
|
|
43
|
+
'opacity',
|
|
44
|
+
'border-radius',
|
|
45
|
+
'border-top-left-radius',
|
|
46
|
+
'border-top-right-radius',
|
|
47
|
+
'border-bottom-right-radius',
|
|
48
|
+
'border-bottom-left-radius',
|
|
35
49
|
] as const
|
|
36
50
|
|
|
37
51
|
export type StyleProperty = (typeof STYLE_PROPERTIES)[number]
|
|
38
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The longhands a shorthand write drops, so a later longhand in the attribute
|
|
55
|
+
* does not keep overriding the value just set. A longhand write leaves its
|
|
56
|
+
* shorthand alone, since the longhand comes later and already wins.
|
|
57
|
+
*/
|
|
58
|
+
const SHORTHAND_LONGHANDS: Readonly<
|
|
59
|
+
Partial<Record<StyleProperty, readonly string[]>>
|
|
60
|
+
> = {
|
|
61
|
+
'border-radius': [
|
|
62
|
+
'border-top-left-radius',
|
|
63
|
+
'border-top-right-radius',
|
|
64
|
+
'border-bottom-right-radius',
|
|
65
|
+
'border-bottom-left-radius',
|
|
66
|
+
],
|
|
67
|
+
padding: ['padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
export type EditProperty = StyleProperty | 'text'
|
|
40
71
|
|
|
41
72
|
export interface EditChange {
|
|
@@ -106,10 +137,13 @@ function checkChange(change: EditChange): EditRefused | undefined {
|
|
|
106
137
|
return undefined
|
|
107
138
|
}
|
|
108
139
|
|
|
140
|
+
const CHARACTER_REFERENCE = /&(?:#\d+|#x[0-9a-f]+|[a-z][a-z0-9]*);/iy
|
|
141
|
+
|
|
109
142
|
/**
|
|
110
143
|
* Splits a style attribute at each semicolon outside parentheses and quotes,
|
|
111
144
|
* keeping every segment's own spacing so an untouched one is written back as
|
|
112
|
-
* it was.
|
|
145
|
+
* it was. The rewriter hands the attribute over undecoded and writes a quote
|
|
146
|
+
* back as `"`, so a character reference's own semicolon never splits.
|
|
113
147
|
*/
|
|
114
148
|
function declarations(style: string): string[] {
|
|
115
149
|
const segments: string[] = []
|
|
@@ -118,7 +152,10 @@ function declarations(style: string): string[] {
|
|
|
118
152
|
let start = 0
|
|
119
153
|
for (let at = 0; at < style.length; at += 1) {
|
|
120
154
|
const char = style[at]
|
|
121
|
-
|
|
155
|
+
CHARACTER_REFERENCE.lastIndex = at
|
|
156
|
+
if (char === '&' && CHARACTER_REFERENCE.test(style)) {
|
|
157
|
+
at = CHARACTER_REFERENCE.lastIndex - 1
|
|
158
|
+
} else if (quote) {
|
|
122
159
|
if (char === quote) quote = undefined
|
|
123
160
|
} else if (char === '"' || char === "'") quote = char
|
|
124
161
|
else if (char === '(') depth += 1
|
|
@@ -139,15 +176,18 @@ function propertyOf(segment: string): string {
|
|
|
139
176
|
/**
|
|
140
177
|
* Sets one property in a style attribute, or drops it on an empty value. The
|
|
141
178
|
* first declaration of it is replaced in place and any later one dropped, so
|
|
142
|
-
* nothing further in the attribute overrides the edit.
|
|
143
|
-
* style is left.
|
|
179
|
+
* nothing further in the attribute overrides the edit. A shorthand also drops
|
|
180
|
+
* its longhands. Undefined means no style is left.
|
|
144
181
|
*/
|
|
145
182
|
export function setDeclaration(
|
|
146
183
|
style: string,
|
|
147
184
|
property: StyleProperty,
|
|
148
185
|
value: string,
|
|
149
186
|
): string | undefined {
|
|
150
|
-
const
|
|
187
|
+
const longhands = SHORTHAND_LONGHANDS[property] ?? []
|
|
188
|
+
const segments = declarations(style).filter(
|
|
189
|
+
(segment) => !longhands.includes(propertyOf(segment)),
|
|
190
|
+
)
|
|
151
191
|
const matches = segments.filter((segment) => propertyOf(segment) === property)
|
|
152
192
|
const declaration = `${property}: ${value.trim()}`
|
|
153
193
|
|
|
@@ -164,7 +204,10 @@ export function setDeclaration(
|
|
|
164
204
|
return [`${lead}${declaration}`]
|
|
165
205
|
})
|
|
166
206
|
} else {
|
|
167
|
-
const kept =
|
|
207
|
+
const kept = segments
|
|
208
|
+
.join(';')
|
|
209
|
+
.replace(/^[\s;]+/, '')
|
|
210
|
+
.replace(/[\s;]*$/, '')
|
|
168
211
|
return kept === '' ? declaration : `${kept}; ${declaration}`
|
|
169
212
|
}
|
|
170
213
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
type ContentRefused,
|
|
5
|
+
canvasDir,
|
|
6
|
+
isValidName,
|
|
7
|
+
readPage,
|
|
8
|
+
TEMP_SUFFIX,
|
|
9
|
+
withFileLock,
|
|
10
|
+
} from '@/canvas/content'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Which frames a session says it is editing, beside `selection.json`. The
|
|
14
|
+
* server cannot see a write until it lands, so the mark is a record a session
|
|
15
|
+
* writes rather than something inferred, and it gates no edit.
|
|
16
|
+
*/
|
|
17
|
+
export const EDITING_FILE = 'editing.json'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How long a mark lives unrenewed. A session that crashes or compacts never
|
|
21
|
+
* clears its mark, so this is how long a dead one can mislead the operator.
|
|
22
|
+
*/
|
|
23
|
+
export const EDITING_TTL_MS = 5 * 60_000
|
|
24
|
+
|
|
25
|
+
export interface EditingMark {
|
|
26
|
+
readonly page: string
|
|
27
|
+
readonly frame: string
|
|
28
|
+
readonly by: string
|
|
29
|
+
readonly since: string
|
|
30
|
+
readonly until: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Stored = Omit<EditingMark, 'page' | 'frame'>
|
|
34
|
+
|
|
35
|
+
export type MarkOutcome =
|
|
36
|
+
| { readonly ok: true; readonly mark: EditingMark }
|
|
37
|
+
| ContentRefused
|
|
38
|
+
|
|
39
|
+
export type ClearOutcome =
|
|
40
|
+
| { readonly ok: true; readonly cleared: boolean }
|
|
41
|
+
| ContentRefused
|
|
42
|
+
|
|
43
|
+
function recordPath(root: string): string {
|
|
44
|
+
return join(canvasDir(root), EDITING_FILE)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isStored(value: unknown): value is Stored {
|
|
48
|
+
if (typeof value !== 'object' || value === null) return false
|
|
49
|
+
const { by, since, until } = value as Record<string, unknown>
|
|
50
|
+
return (
|
|
51
|
+
typeof by === 'string' &&
|
|
52
|
+
typeof since === 'string' &&
|
|
53
|
+
typeof until === 'string' &&
|
|
54
|
+
!Number.isNaN(Date.parse(until))
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every well-formed entry, with a malformed file read as holding none. */
|
|
59
|
+
function readStored(root: string): Map<string, Stored> {
|
|
60
|
+
const entries = new Map<string, Stored>()
|
|
61
|
+
let parsed: unknown
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(readFileSync(recordPath(root), 'utf8'))
|
|
64
|
+
} catch {
|
|
65
|
+
return entries
|
|
66
|
+
}
|
|
67
|
+
if (typeof parsed !== 'object' || parsed === null) return entries
|
|
68
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
69
|
+
if (isStored(value)) entries.set(key, value)
|
|
70
|
+
}
|
|
71
|
+
return entries
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toMark(key: string, stored: Stored): EditingMark | undefined {
|
|
75
|
+
const [page, frame, ...rest] = key.split('/')
|
|
76
|
+
if (!page || !frame || rest.length > 0) return undefined
|
|
77
|
+
return { page, frame, ...stored }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A mark still inside its expiry, on a frame still on disk. */
|
|
81
|
+
function isLive(root: string, mark: EditingMark, now: Date): boolean {
|
|
82
|
+
if (Date.parse(mark.until) <= now.getTime()) return false
|
|
83
|
+
return (
|
|
84
|
+
readPage(root, mark.page)?.frames.some(
|
|
85
|
+
(candidate) => candidate.name === mark.frame,
|
|
86
|
+
) ?? false
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function liveMarks(
|
|
91
|
+
root: string,
|
|
92
|
+
entries: ReadonlyMap<string, Stored>,
|
|
93
|
+
now: Date,
|
|
94
|
+
): EditingMark[] {
|
|
95
|
+
return [...entries]
|
|
96
|
+
.flatMap(([key, stored]) => toMark(key, stored) ?? [])
|
|
97
|
+
.filter((mark) => isLive(root, mark, now))
|
|
98
|
+
.sort((a, b) =>
|
|
99
|
+
`${a.page}/${a.frame}`.localeCompare(`${b.page}/${b.frame}`),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Replaces the record through a rename, keeping only live marks, so the file
|
|
105
|
+
* never grows with marks nobody cleared. The caller holds the lock, which is
|
|
106
|
+
* what keeps two sessions marking at once from dropping each other's key.
|
|
107
|
+
*/
|
|
108
|
+
function writeMarks(root: string, marks: readonly EditingMark[]): void {
|
|
109
|
+
const record = Object.fromEntries(
|
|
110
|
+
marks.map(({ page, frame, ...stored }) => [`${page}/${frame}`, stored]),
|
|
111
|
+
)
|
|
112
|
+
const target = recordPath(root)
|
|
113
|
+
const temp = `${target}.${process.pid}${TEMP_SUFFIX}`
|
|
114
|
+
writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`)
|
|
115
|
+
renameSync(temp, target)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function markEditing(
|
|
119
|
+
root: string,
|
|
120
|
+
page: string,
|
|
121
|
+
frame: string,
|
|
122
|
+
by: string,
|
|
123
|
+
now: Date,
|
|
124
|
+
): MarkOutcome {
|
|
125
|
+
if (!isValidName(page) || !isValidName(frame)) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
reason: 'invalid-name',
|
|
129
|
+
detail: `${page}/${frame} is not a valid frame`,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const onPage = readPage(root, page)
|
|
133
|
+
if (!onPage) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: 'no-page',
|
|
137
|
+
detail: `page ${page} does not exist`,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!onPage.frames.some((candidate) => candidate.name === frame)) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
reason: 'no-frame',
|
|
144
|
+
detail: `frame ${frame} does not exist on ${page}`,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const mark: EditingMark = {
|
|
149
|
+
page,
|
|
150
|
+
frame,
|
|
151
|
+
by,
|
|
152
|
+
since: now.toISOString(),
|
|
153
|
+
until: new Date(now.getTime() + EDITING_TTL_MS).toISOString(),
|
|
154
|
+
}
|
|
155
|
+
return withFileLock(recordPath(root), (): MarkOutcome => {
|
|
156
|
+
const entries = readStored(root)
|
|
157
|
+
entries.set(`${page}/${frame}`, {
|
|
158
|
+
by,
|
|
159
|
+
since: mark.since,
|
|
160
|
+
until: mark.until,
|
|
161
|
+
})
|
|
162
|
+
writeMarks(root, liveMarks(root, entries, now))
|
|
163
|
+
return { ok: true, mark }
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Drops one frame's mark. Clearing a frame that holds none succeeds and writes
|
|
169
|
+
* nothing, so a session clearing defensively never fails, and a frame removed
|
|
170
|
+
* while marked still clears.
|
|
171
|
+
*/
|
|
172
|
+
export function clearEditing(
|
|
173
|
+
root: string,
|
|
174
|
+
page: string,
|
|
175
|
+
frame: string,
|
|
176
|
+
): ClearOutcome {
|
|
177
|
+
if (!isValidName(page) || !isValidName(frame)) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
reason: 'invalid-name',
|
|
181
|
+
detail: `${page}/${frame} is not a valid frame`,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!existsSync(recordPath(root))) return { ok: true, cleared: false }
|
|
185
|
+
|
|
186
|
+
return withFileLock(recordPath(root), (): ClearOutcome => {
|
|
187
|
+
const entries = readStored(root)
|
|
188
|
+
if (!entries.delete(`${page}/${frame}`)) return { ok: true, cleared: false }
|
|
189
|
+
writeMarks(
|
|
190
|
+
root,
|
|
191
|
+
[...entries].flatMap(([key, stored]) => toMark(key, stored) ?? []),
|
|
192
|
+
)
|
|
193
|
+
return { ok: true, cleared: true }
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The live marks, sorted by frame. A mark past its expiry or on a frame since
|
|
199
|
+
* removed reads as absent with no write, so a reader never gets a dead name.
|
|
200
|
+
*/
|
|
201
|
+
export function readEditing(root: string, now: Date): EditingMark[] {
|
|
202
|
+
return liveMarks(root, readStored(root), now)
|
|
203
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The screen color sampler, which only Chromium ships. TypeScript's DOM
|
|
3
|
+
* library carries no type for it, so a window without it reads as undefined.
|
|
4
|
+
*/
|
|
5
|
+
interface EyeDropperResult {
|
|
6
|
+
readonly sRGBHex: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface EyeDropperSampler {
|
|
10
|
+
open(options?: { readonly signal?: AbortSignal }): Promise<EyeDropperResult>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Window {
|
|
14
|
+
readonly EyeDropper?: new () => EyeDropperSampler
|
|
15
|
+
}
|
package/src/canvas/server.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type FSWatcher, mkdirSync, watch } from 'node:fs'
|
|
2
2
|
import {
|
|
3
|
+
countElements,
|
|
3
4
|
type ElementAddress,
|
|
4
5
|
HASH_ATTRIBUTE,
|
|
6
|
+
IMPLIED_ATTRIBUTE,
|
|
5
7
|
TOKENS_ATTRIBUTE,
|
|
6
8
|
} from '@/canvas/address'
|
|
7
9
|
import {
|
|
@@ -12,10 +14,12 @@ import {
|
|
|
12
14
|
LOCK_SUFFIX,
|
|
13
15
|
moveFrame,
|
|
14
16
|
readSelection,
|
|
17
|
+
resizeFrame,
|
|
15
18
|
TEMP_SUFFIX,
|
|
16
19
|
writeSelection,
|
|
17
20
|
} from '@/canvas/content'
|
|
18
21
|
import { type EditRefused, editFrame } from '@/canvas/edit'
|
|
22
|
+
import { readEditing } from '@/canvas/editing'
|
|
19
23
|
import {
|
|
20
24
|
resolveFrameTokens,
|
|
21
25
|
type TokenOptions,
|
|
@@ -90,24 +94,125 @@ function styleElement(css: string, hash: string | undefined): string {
|
|
|
90
94
|
return `<style ${TOKENS_ATTRIBUTE}${stamp}>${css.replaceAll('</style', '<\\/style')}</style>`
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
/** The wrappers a browser builds whether or not the file states them. */
|
|
98
|
+
const WRAPPERS = new Set(['html', 'head', 'body'])
|
|
99
|
+
|
|
100
|
+
/** Which of the document wrappers the file states, counted as the address counts. */
|
|
101
|
+
function statedWrappers(html: string): Set<string> {
|
|
102
|
+
const stated = new Set<string>()
|
|
103
|
+
countElements(new HTMLRewriter(), (element) => {
|
|
104
|
+
const tag = element.tagName.toLowerCase()
|
|
105
|
+
if (WRAPPERS.has(tag)) stated.add(tag)
|
|
106
|
+
return undefined
|
|
107
|
+
}).transform(html)
|
|
108
|
+
return stated
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Prepends markup inside the first element a selector matches. */
|
|
112
|
+
function prependFirst(html: string, selector: string, markup: string): string {
|
|
113
|
+
let isDone = false
|
|
114
|
+
return new HTMLRewriter()
|
|
115
|
+
.on(selector, {
|
|
116
|
+
element(element) {
|
|
117
|
+
if (isDone) return
|
|
118
|
+
isDone = true
|
|
119
|
+
element.prepend(markup, { html: true })
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
.transform(html)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What a browser parses into the head it builds ahead of a fragment's first
|
|
127
|
+
* content. A comment or whitespace among them stays in that head too.
|
|
128
|
+
*/
|
|
129
|
+
const HEAD_CONTENT = new Set([
|
|
130
|
+
'base',
|
|
131
|
+
'link',
|
|
132
|
+
'meta',
|
|
133
|
+
'noscript',
|
|
134
|
+
'script',
|
|
135
|
+
'style',
|
|
136
|
+
'template',
|
|
137
|
+
'title',
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Opens a marked body where the browser would build its own: ahead of the
|
|
142
|
+
* first element or text outside the head content a file leads with, or at the
|
|
143
|
+
* end when the file holds nothing else.
|
|
144
|
+
*/
|
|
145
|
+
function openBodyBeforeContent(html: string, marker: string): string {
|
|
146
|
+
let isOpen = false
|
|
147
|
+
let headDepth = 0
|
|
148
|
+
const open = (target: HTMLRewriterTypes.Element | HTMLRewriterTypes.Text) => {
|
|
149
|
+
isOpen = true
|
|
150
|
+
target.before(marker, { html: true })
|
|
151
|
+
}
|
|
152
|
+
return new HTMLRewriter()
|
|
153
|
+
.on('*', {
|
|
154
|
+
element(element) {
|
|
155
|
+
if (isOpen || headDepth > 0) return
|
|
156
|
+
const tag = element.tagName.toLowerCase()
|
|
157
|
+
if (tag === 'html') return
|
|
158
|
+
if (!HEAD_CONTENT.has(tag)) {
|
|
159
|
+
open(element)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
if (!element.canHaveContent || element.selfClosing) return
|
|
163
|
+
headDepth += 1
|
|
164
|
+
element.onEndTag(() => {
|
|
165
|
+
headDepth -= 1
|
|
166
|
+
})
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
.onDocument({
|
|
170
|
+
text(chunk) {
|
|
171
|
+
if (!isOpen && headDepth === 0 && chunk.text.trim() !== '') open(chunk)
|
|
172
|
+
},
|
|
173
|
+
end(end) {
|
|
174
|
+
if (!isOpen) end.append(marker, { html: true })
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
.transform(html)
|
|
178
|
+
}
|
|
179
|
+
|
|
93
180
|
/**
|
|
94
181
|
* Puts the tokens first in the head, so a stylesheet the frame links itself
|
|
95
|
-
* still wins the cascade. A
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
182
|
+
* still wins the cascade. A hash stamps the element even when no tokens
|
|
183
|
+
* resolve, since the shell sends it back with an element pick.
|
|
184
|
+
*
|
|
185
|
+
* A wrapper the file leaves out is written in marked, since the browser would
|
|
186
|
+
* build it anyway and the shell's count would run past the file's. An `html`
|
|
187
|
+
* goes right after the doctype, which keeps the frame out of quirks mode. A
|
|
188
|
+
* marked `head` is left open, so the head content a fragment leads with parses
|
|
189
|
+
* into it the way it would into the head a browser builds, and a file stating
|
|
190
|
+
* neither `head` nor `body` gets a marked body opened where that content ends.
|
|
191
|
+
* A file stating a `head` but no `body` is left to the browser.
|
|
99
192
|
*/
|
|
100
193
|
export function injectTokens(html: string, css: string, hash?: string): string {
|
|
101
194
|
if (css === '' && hash === undefined) return html
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
195
|
+
const style = styleElement(css, hash)
|
|
196
|
+
const stated = statedWrappers(html)
|
|
197
|
+
const isHeadStated = stated.has('head')
|
|
198
|
+
const withBody =
|
|
199
|
+
isHeadStated || stated.has('body')
|
|
200
|
+
? html
|
|
201
|
+
: openBodyBeforeContent(html, `<body ${IMPLIED_ATTRIBUTE}>`)
|
|
202
|
+
if (isHeadStated) {
|
|
203
|
+
const withHead = prependFirst(withBody, 'head', style)
|
|
204
|
+
return stated.has('html') ? withHead : markedHtml(withHead, '')
|
|
108
205
|
}
|
|
109
|
-
const
|
|
110
|
-
return
|
|
206
|
+
const head = `<head ${IMPLIED_ATTRIBUTE}>${style}`
|
|
207
|
+
return stated.has('html')
|
|
208
|
+
? prependFirst(withBody, 'html', head)
|
|
209
|
+
: markedHtml(withBody, head)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Writes a marked `html` and what follows it right after the doctype. */
|
|
213
|
+
function markedHtml(html: string, lead: string): string {
|
|
214
|
+
const doctype = html.match(/^\s*<!doctype[^>]*>/i)?.[0] ?? ''
|
|
215
|
+
return `${doctype}<html ${IMPLIED_ATTRIBUTE}>${lead}${html.slice(doctype.length)}`
|
|
111
216
|
}
|
|
112
217
|
|
|
113
218
|
/**
|
|
@@ -348,6 +453,11 @@ export function startCanvas(
|
|
|
348
453
|
hostname: SERVE_HOST,
|
|
349
454
|
port,
|
|
350
455
|
development: false,
|
|
456
|
+
/*
|
|
457
|
+
* Production mode turns port reuse on, so a second canvas would bind
|
|
458
|
+
* beside one already listening rather than walk past it.
|
|
459
|
+
*/
|
|
460
|
+
reusePort: false,
|
|
351
461
|
/* The change stream stays open for as long as the shell does. */
|
|
352
462
|
idleTimeout: 0,
|
|
353
463
|
routes: {
|
|
@@ -374,6 +484,9 @@ export function startCanvas(
|
|
|
374
484
|
},
|
|
375
485
|
}),
|
|
376
486
|
},
|
|
487
|
+
editing: readEditing(root, new Date()).map(
|
|
488
|
+
({ page, frame, by, until }) => ({ page, frame, by, until }),
|
|
489
|
+
),
|
|
377
490
|
})
|
|
378
491
|
}),
|
|
379
492
|
'/api/frames/move': {
|
|
@@ -396,6 +509,30 @@ export function startCanvas(
|
|
|
396
509
|
}),
|
|
397
510
|
),
|
|
398
511
|
},
|
|
512
|
+
'/api/frames/resize': {
|
|
513
|
+
POST: guarded(
|
|
514
|
+
mutation((body) => {
|
|
515
|
+
if (
|
|
516
|
+
!isRecord(body) ||
|
|
517
|
+
typeof body.page !== 'string' ||
|
|
518
|
+
typeof body.frame !== 'string' ||
|
|
519
|
+
typeof body.x !== 'number' ||
|
|
520
|
+
typeof body.y !== 'number' ||
|
|
521
|
+
typeof body.width !== 'number' ||
|
|
522
|
+
typeof body.height !== 'number'
|
|
523
|
+
) {
|
|
524
|
+
return badBody('send page, frame, x, y, width, and height')
|
|
525
|
+
}
|
|
526
|
+
const outcome = resizeFrame(root, body.page, body.frame, {
|
|
527
|
+
x: body.x,
|
|
528
|
+
y: body.y,
|
|
529
|
+
width: body.width,
|
|
530
|
+
height: body.height,
|
|
531
|
+
})
|
|
532
|
+
return outcome.ok ? json(outcome) : refusal(outcome)
|
|
533
|
+
}),
|
|
534
|
+
),
|
|
535
|
+
},
|
|
399
536
|
'/api/frames/edit': {
|
|
400
537
|
POST: guarded(
|
|
401
538
|
mutation((body) => {
|
package/src/canvas/tokens.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR } from '@/design/adapter'
|
|
4
|
-
import { buildDesignCss } from '@/design/css'
|
|
5
|
-
import {
|
|
4
|
+
import { buildDesignCss, fontFaceBlock } from '@/design/css'
|
|
5
|
+
import {
|
|
6
|
+
FONT_FACES,
|
|
7
|
+
HAND_DRAWN_FONT_FACES,
|
|
8
|
+
type FontFace,
|
|
9
|
+
} from '@/design/fonts'
|
|
10
|
+
import {
|
|
11
|
+
type DroppedFace,
|
|
12
|
+
inlineProjectFaces,
|
|
13
|
+
namedFamilies,
|
|
14
|
+
projectSheets,
|
|
15
|
+
} from '@/design/project-fonts'
|
|
16
|
+
import { isOwnCheckout } from '@/roots/project'
|
|
6
17
|
|
|
7
18
|
/**
|
|
8
19
|
* The stylesheet injected into every frame, so a frame drawn with
|
|
@@ -17,6 +28,8 @@ export type FrameTokens =
|
|
|
17
28
|
readonly css: string
|
|
18
29
|
/** Relative to the root, in the order the stylesheet concatenates them. */
|
|
19
30
|
readonly files: readonly string[]
|
|
31
|
+
/** Project faces left out of the sheet, present only when one was. */
|
|
32
|
+
readonly dropped?: readonly DroppedFace[]
|
|
20
33
|
}
|
|
21
34
|
| { readonly source: 'none'; readonly css: ''; readonly notice: string }
|
|
22
35
|
|
|
@@ -113,19 +126,44 @@ export function tokenGroups(css: string): TokenGroup[] {
|
|
|
113
126
|
|
|
114
127
|
/** The installed files in cascade order: the base first, then each override. */
|
|
115
128
|
function installedFiles(root: string): string[] {
|
|
116
|
-
const files: string[] = []
|
|
117
129
|
const base = join(DESIGN_INSTALL_DIR, BASE_FILE)
|
|
118
|
-
|
|
130
|
+
return [
|
|
131
|
+
...(existsSync(join(root, base)) ? [base] : []),
|
|
132
|
+
...projectSheets(root),
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The vendored faces the installed sheets name and no project rule already
|
|
138
|
+
* declares, so a target renders the base's families on a machine that never
|
|
139
|
+
* installed them, and a project face of the same family wins outright.
|
|
140
|
+
*/
|
|
141
|
+
function toolkitFaces(css: string, declared: ReadonlySet<string>): FontFace[] {
|
|
142
|
+
const named = namedFamilies(css)
|
|
143
|
+
return [...FONT_FACES, ...HAND_DRAWN_FONT_FACES].filter(
|
|
144
|
+
(face) => named.has(face.family) && !declared.has(face.family),
|
|
145
|
+
)
|
|
146
|
+
}
|
|
119
147
|
|
|
148
|
+
/**
|
|
149
|
+
* A project sheet with its local `url()`s inlined, since a frame is served
|
|
150
|
+
* from the content folder and cannot reach a path under `.claude/design/`.
|
|
151
|
+
*/
|
|
152
|
+
function installedSheets(root: string, files: readonly string[]) {
|
|
120
153
|
const projectDir = join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
154
|
+
const dropped: DroppedFace[] = []
|
|
155
|
+
const declared = new Set<string>()
|
|
156
|
+
const css = files
|
|
157
|
+
.map((file) => {
|
|
158
|
+
const raw = readFileSync(join(root, file), 'utf8')
|
|
159
|
+
if (!file.startsWith(projectDir)) return `/* ${file} */\n${raw}`
|
|
160
|
+
const inlined = inlineProjectFaces(root, file, raw)
|
|
161
|
+
dropped.push(...inlined.dropped)
|
|
162
|
+
for (const family of inlined.declared) declared.add(family)
|
|
163
|
+
return `/* ${file} */\n${inlined.css}`
|
|
164
|
+
})
|
|
165
|
+
.join('\n')
|
|
166
|
+
return { css, dropped, declared }
|
|
129
167
|
}
|
|
130
168
|
|
|
131
169
|
/**
|
|
@@ -141,7 +179,7 @@ export function resolveFrameTokens(
|
|
|
141
179
|
/*
|
|
142
180
|
* Faces embedded, since the type tokens name `Geist Variable` and a
|
|
143
181
|
* machine with only static Geist installed renders every frame in the
|
|
144
|
-
* fallback.
|
|
182
|
+
* fallback.
|
|
145
183
|
*/
|
|
146
184
|
return {
|
|
147
185
|
source: 'toolkit',
|
|
@@ -151,10 +189,14 @@ export function resolveFrameTokens(
|
|
|
151
189
|
|
|
152
190
|
const files = installedFiles(root)
|
|
153
191
|
if (files.length > 0) {
|
|
154
|
-
const css = files
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
192
|
+
const { css, dropped, declared } = installedSheets(root, files)
|
|
193
|
+
const faces = toolkitFaces(css, declared)
|
|
194
|
+
return {
|
|
195
|
+
source: 'installed',
|
|
196
|
+
css: faces.length > 0 ? `${fontFaceBlock(faces)}\n${css}` : css,
|
|
197
|
+
files,
|
|
198
|
+
...(dropped.length > 0 && { dropped }),
|
|
199
|
+
}
|
|
158
200
|
}
|
|
159
201
|
|
|
160
202
|
return { source: 'none', css: '', notice: NONE_NOTICE }
|
package/src/census/count.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { extname, join } from 'node:path'
|
|
3
3
|
import { isBinary } from '@/sweep/binary'
|
|
4
|
-
import { listRepositoryFiles } from '@/git
|
|
4
|
+
import { listRepositoryFiles } from '@/git/files'
|
|
5
5
|
|
|
6
6
|
export type CensusRefusal = 'no-git'
|
|
7
7
|
|
package/src/claude/seeds.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname, join, relative } from 'node:path'
|
|
4
|
-
import { copyPreservingMode } from '@/copy'
|
|
5
|
-
import { creationRel, isRecordEntry } from '@/record
|
|
4
|
+
import { copyPreservingMode } from '@/sync/copy'
|
|
5
|
+
import { creationRel, isRecordEntry } from '@/roots/record'
|
|
6
6
|
import { rewritesOnInstall, stripSeedMarker } from '@/tooling/seed-marker'
|
|
7
|
-
import { SURFACE_ENTRIES, surfaceDir } from '@/surface
|
|
7
|
+
import { SURFACE_ENTRIES, surfaceDir } from '@/roots/surface'
|
|
8
8
|
|
|
9
9
|
const SEEDS_DIR = join('tooling', 'claude', 'seeds')
|
|
10
10
|
const CLAUDE_DIR = '.claude'
|