@erclx/canon 5.79.0 → 5.80.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/CHANGELOG.md +17 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +13 -10
- package/claude/skills/design-pick/SKILL.md +8 -0
- package/claude/skills/design-pick/references/design-config.md +9 -6
- package/claude/skills/design-references/REQUIREMENT.md +32 -0
- package/claude/skills/design-references/SKILL.md +120 -0
- package/claude/skills/design-references/references/films.md +90 -0
- package/claude/skills/design-references/references/sites.md +36 -0
- package/claude/skills/design-taste/SKILL.md +16 -4
- package/claude/skills/design-taste/references/adopted.md +5 -1
- package/claude/skills/design-taste/references/kinds.md +1 -0
- package/claude/skills/design-taste/references/preflight.md +27 -1
- package/claude/skills/design-taste/references/tells.md +2 -0
- package/claude/skills/review-ui/references/driving.md +33 -1
- package/claude/skills/search-craft/REQUIREMENT.md +3 -3
- package/claude/skills/search-craft/SKILL.md +12 -1
- package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
- package/claude/skills/target-scaffold/SKILL.md +44 -0
- package/claude/skills/target-setup/SKILL.md +5 -1
- package/claude/skills/ui-checklist/SKILL.md +5 -1
- package/claude/skills/ui-craft/SKILL.md +9 -9
- package/claude/skills/ui-craft/references/adopted.md +4 -2
- package/claude/skills/ui-craft/references/capture.md +8 -0
- package/claude/skills/ui-craft/references/controls.md +6 -0
- package/claude/skills/ui-craft/references/motion.md +6 -0
- package/claude/skills/ui-film/references/take-craft.md +2 -0
- package/docs/agents/output-shape.md +2 -1
- package/docs/target/projects.md +2 -2
- package/docs/workflow/skill-map.md +7 -5
- package/docs/workflow/visual-design-workflow.md +4 -0
- package/governance/ledger/skills/design-references/SKILL.md +38 -0
- package/governance/ledger/skills/design-taste/SKILL.md +36 -30
- package/governance/ledger/skills/design-taste/kinds.md +1 -0
- package/governance/ledger/skills/design-taste/tells.md +22 -20
- package/governance/ledger/skills/search-craft/SKILL.md +6 -0
- package/governance/ledger/skills/ui-craft/capture.md +10 -0
- package/governance/ledger/skills/ui-craft/controls.md +8 -0
- package/governance/ledger/skills/ui-craft/motion.md +9 -1
- package/package.json +3 -3
- package/src/capture/render.ts +154 -2
- package/src/capture/sections.ts +257 -0
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/cases/setup.ts +7 -0
- package/src/commands/capture.ts +149 -4
- package/src/commands/tooling.ts +72 -0
- package/src/take/perform.ts +29 -4
- package/src/tooling/list.ts +3 -0
- package/src/tooling/scaffold.ts +193 -0
- package/tooling/cloudflare/manifest.toml +1 -1
- package/tooling/cloudflare/reference.md +8 -0
|
@@ -21,29 +21,31 @@ The main catalog is Taste-Skill's v2 experimental skill, which its author moved
|
|
|
21
21
|
|
|
22
22
|
## Composition
|
|
23
23
|
|
|
24
|
-
| Bullet
|
|
25
|
-
|
|
|
26
|
-
| The three equal cards
|
|
27
|
-
| One section shape repeated
|
|
28
|
-
| The centered hero
|
|
29
|
-
| Decoration as structure
|
|
30
|
-
|
|
|
24
|
+
| Bullet | Source | Read |
|
|
25
|
+
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
26
|
+
| The three equal cards | Catalog observation, [Redesign skill](https://github.com/nxpatterns/claude-taste-skill/blob/main/skills/redesign-skill/SKILL.md): "Three equal card columns as feature row. This is the most generic AI layout." | 2026-10-24 |
|
|
27
|
+
| One section shape repeated | Catalog observation, [Taste-Skill tells](https://github.com/leonxlnx/taste-skill/blob/main/skills/taste-skill/references/tells.md): "Every section the same layout family" is a structural tell, reached past with "Different composition per section" | 2026-10-24 |
|
|
28
|
+
| The centered hero | Catalog observation, [Taste-Skill tells](https://github.com/leonxlnx/taste-skill/blob/main/skills/taste-skill/references/tells.md): "Centred headline, subtitle, two buttons, screenshot below" | 2026-10-24 |
|
|
29
|
+
| Decoration as structure | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): "NO crosshair / hairline grid lines as decoration", kept "only when they organize real content" | 2026-10-24 |
|
|
30
|
+
| A figure of identical parts | [Wikipedia, Chartjunk](https://en.wikipedia.org/wiki/Chartjunk), quoting Tufte: "The interior decoration of graphics generates a lot of ink that does not tell the viewer anything new." | 2026-11-08 |
|
|
31
|
+
| The corner floater | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): "a small explainer paragraph floating with no clear alignment to anything else. That floater is the Tell." | 2026-10-24 |
|
|
31
32
|
|
|
32
33
|
## Content and labels
|
|
33
34
|
|
|
34
|
-
| Bullet | Source
|
|
35
|
-
| -------------------------- |
|
|
36
|
-
| Enumerated eyebrows | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): generic step labels are banned, since "The actual step content is the label"
|
|
37
|
-
| Poetic section labels | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): "Field notes" and "On our desks" style labels read "as performative-craftsman. Use plain functional labels"
|
|
38
|
-
| Atmospheric strips | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): "NO weather / locale strips" and "NO version footers on marketing pages"
|
|
39
|
-
| Scroll cues | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): "Scroll cues are banned." Readers "know what scroll is."
|
|
40
|
-
| Decorative status dots | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): a dot is "Only acceptable when conveying real semantic state" and "limited to one per page section"
|
|
41
|
-
| Filler verbs | Catalog observation, [Redesign skill](https://github.com/nxpatterns/claude-taste-skill/blob/main/skills/redesign-skill/SKILL.md): "Never use 'Elevate', 'Seamless', 'Unleash', 'Next-Gen'", and "Write plain, specific language."
|
|
42
|
-
| Placeholder as placeholder | Catalog observation, [Redesign skill](https://github.com/nxpatterns/claude-taste-skill/blob/main/skills/redesign-skill/SKILL.md): "Generic names like 'John Doe'" and "Placeholder company names like 'Acme Corp'"
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
|
|
|
35
|
+
| Bullet | Source | Read |
|
|
36
|
+
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
37
|
+
| Enumerated eyebrows | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): generic step labels are banned, since "The actual step content is the label" | 2026-10-24 |
|
|
38
|
+
| Poetic section labels | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): "Field notes" and "On our desks" style labels read "as performative-craftsman. Use plain functional labels" | 2026-10-24 |
|
|
39
|
+
| Atmospheric strips | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): "NO weather / locale strips" and "NO version footers on marketing pages" | 2026-10-24 |
|
|
40
|
+
| Scroll cues | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): "Scroll cues are banned." Readers "know what scroll is." | 2026-10-24 |
|
|
41
|
+
| Decorative status dots | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): a dot is "Only acceptable when conveying real semantic state" and "limited to one per page section" | 2026-10-24 |
|
|
42
|
+
| Filler verbs | Catalog observation, [Redesign skill](https://github.com/nxpatterns/claude-taste-skill/blob/main/skills/redesign-skill/SKILL.md): "Never use 'Elevate', 'Seamless', 'Unleash', 'Next-Gen'", and "Write plain, specific language." | 2026-10-24 |
|
|
43
|
+
| Placeholder as placeholder | Catalog observation, [Redesign skill](https://github.com/nxpatterns/claude-taste-skill/blob/main/skills/redesign-skill/SKILL.md): "Generic names like 'John Doe'" and "Placeholder company names like 'Acme Corp'" | 2026-10-24 |
|
|
44
|
+
| Template token in a mock | Catalog observation, [Redesign skill](https://github.com/nxpatterns/claude-taste-skill/blob/main/skills/redesign-skill/SKILL.md): "Lorem Ipsum. Never use placeholder latin text. Write real draft copy." The copied real id is a House choice: in a mock it reads as a reference a reader may follow | 2026-11-08 |
|
|
45
|
+
| Invented precision | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): "Fake-precise numbers are flagged", passing only when they "Come from real data" or "Are explicitly labeled as mock" | 2026-10-24 |
|
|
46
|
+
| The fake screenshot | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): "NO div-based fake product UI in the hero", which it ranks first among the tells | 2026-10-24 |
|
|
47
|
+
| Copy nobody re-read | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): "re-read every visible string on the page", since "AI-generated cute copy is worse than boring copy" | 2026-10-24 |
|
|
48
|
+
| What the re-read rewrites | Catalog observation, [Taste-Skill v2 experimental](https://github.com/Leonxlnx/taste-skill-classics/blob/main/skills/taste-skill-v2-experimental/SKILL.md): the re-read flags a string that is "Grammatically broken", "Has unclear referents", or carries "cute-but-wrong wordplay" | 2026-10-24 |
|
|
47
49
|
|
|
48
50
|
## Surface and palette
|
|
49
51
|
|
|
@@ -45,6 +45,12 @@ The access note's Reddit and X rows record refusals, so they are measurements re
|
|
|
45
45
|
| A public API over a summarized page | [Crossref REST API](https://www.crossref.org/documentation/retrieve-metadata/rest-api/): "Our publicly available REST API exposes the scholarly metadata that members and trusted sources deposit with Crossref" | 2026-10-28 |
|
|
46
46
|
| A plain request rather than a wrapper | [claude-research-stack](https://github.com/aghori3004/claude-research-stack): "The skill talks to the CLIs and MCPs directly, on purpose", listing "No wrapper scripts" among what it leaves out | 2026-10-28 |
|
|
47
47
|
|
|
48
|
+
## Quote what was read
|
|
49
|
+
|
|
50
|
+
| Bullet | Source | Read |
|
|
51
|
+
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
52
|
+
| Quote only what was read | [Caulfield, SIFT (The Four Moves)](https://hapgood.us/2019/06/19/sift-the-four-moves/): "trace the claim, quote, or media back to the source, so you can see it in it's original context" | 2026-11-08 |
|
|
53
|
+
|
|
48
54
|
## Access note
|
|
49
55
|
|
|
50
56
|
| Bullet | Source | Read |
|
|
@@ -45,6 +45,16 @@ The rule states canon's own evidence procedure for a rendered change, so most ro
|
|
|
45
45
|
| State case at one width, driven | House choice: a case driving a menu open tests the state, and repeating it at every layout width multiplies the evidence with no layout to compare, and an initial render reports nothing about a menu that opens, an answer that is chosen, or a rail that tracks scrolling | 2026-10-18 |
|
|
46
46
|
| Case lives with the surface | House choice: a surface with no case ships uncaptured, and a case left after its surface goes fails every later capture run | 2026-10-18 |
|
|
47
47
|
|
|
48
|
+
## What a moving change owes
|
|
49
|
+
|
|
50
|
+
| Bullet | Source | Read |
|
|
51
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
|
52
|
+
| Real input at each width | House choice: on a proximity-snap page, `scrollIntoView` reached section 3 while six spaced wheel notches left `scrollY` at 0, so a scripted scroll passed a page a wheel could not move | 2026-11-08 |
|
|
53
|
+
| Notches at two paces | House choice: on the same page, six notches one call apart stayed at 0 and six notches 50 ms apart reached 522, so each pace catches a stall the other misses | 2026-11-08 |
|
|
54
|
+
| Keyboard through the same | [Understanding Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html): "all functionality of the content is operable through a keyboard interface". Arrow keys and Page Down reached 783 and 1566 on the snap page | 2026-11-08, WCAG 2.2 |
|
|
55
|
+
| Sample at fixed steps | House choice: a read taken only at the end reports where the page settled and hides a notch that moved nothing, which a read after each notch shows | 2026-11-08 |
|
|
56
|
+
| Touch and hover in touch mode | House choice: a desktop session refused a tap and kept `(hover: hover)` true when resized, while a mobile session reported hover false and took the tap | 2026-11-08 |
|
|
57
|
+
|
|
48
58
|
## Sharing a capture
|
|
49
59
|
|
|
50
60
|
| Bullet | Source | Read |
|
|
@@ -23,9 +23,17 @@ The field is interaction design, and the authorities are the Nielsen Norman Grou
|
|
|
23
23
|
| Declare `color-scheme` | [MDN, color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/color-scheme): the user agent adapts "The default colors of scrollbars and other interaction UI" and "The default colors of form controls", and the page opts in by declaring it on the `:root` element | 2026-10-23 |
|
|
24
24
|
| No intermediate flash | [Cumulative Layout Shift](https://web.dev/articles/cls): "Unexpected layout shifts can disrupt the user experience", caused by content "dynamically added to the page before existing content" | 2026-10-18 |
|
|
25
25
|
|
|
26
|
+
## Signifiers
|
|
27
|
+
|
|
28
|
+
| Bullet | Source | Read |
|
|
29
|
+
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
30
|
+
| No control look without an action | [Beyond Blue Links](https://www.nngroup.com/articles/clickable-elements/): "Conversely, don't make nonclickable items look like buttons." [Flat UI Elements Attract Less Attention](https://www.nngroup.com/articles/flat-ui-less-attention-cause-uncertainty/): "UIs with weak signifiers required more user effort than strong ones" | 2026-11-08 |
|
|
31
|
+
| Default cursor on decorative chrome | [MDN, cursor](https://developer.mozilla.org/en-US/docs/Web/CSS/cursor): `pointer` is "a pointer that indicates a link", and `default` is "The platform-dependent default cursor" | 2026-11-08 |
|
|
32
|
+
|
|
26
33
|
## Hover and focus effects
|
|
27
34
|
|
|
28
35
|
| Bullet | Source | Read |
|
|
29
36
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
|
30
37
|
| Hover and focus content | [Understanding Content on Hover or Focus](https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html): additional content is "Dismissible" without moving hover or focus, "Hoverable" so the pointer can move over it, and "Persistent" until "the hover or focus trigger is removed, the user dismisses it, or its information is no longer valid" | 2026-10-23, WCAG 2.2 |
|
|
31
38
|
| No change of context on focus or input | [Understanding On Focus](https://www.w3.org/WAI/WCAG22/Understanding/on-focus.html): "When any user interface component receives focus, it does not initiate a change of context". [Understanding On Input](https://www.w3.org/WAI/WCAG22/Understanding/on-input.html): changing a setting does not cause one "unless the user has been advised of the behavior before using the component" | 2026-10-23, WCAG 2.2 |
|
|
39
|
+
| Hover behind a hover query | [MDN, hover](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover): `none` covers devices where "many mobile devices emulate hovering when the user performs an inconvenient long tap", and `hover` means "The primary input mechanism can conveniently hover over elements." | 2026-11-08 |
|
|
@@ -5,7 +5,7 @@ description: Sources for claude/skills/ui-craft/references/motion.md, one row pe
|
|
|
5
5
|
|
|
6
6
|
# Motion reference ledger
|
|
7
7
|
|
|
8
|
-
The
|
|
8
|
+
The fields are web accessibility, interaction design, and web performance. The W3C, through WCAG 2.2 and the ARIA Authoring Practices Guide, and MDN for the media feature back the accessibility rows. Shneiderman's direct manipulation paper and Material's motion system back reader-paced motion, and web.dev backs the held size.
|
|
9
9
|
|
|
10
10
|
## Moving content
|
|
11
11
|
|
|
@@ -15,6 +15,14 @@ The field is web accessibility, and the authorities are the W3C, through WCAG 2.
|
|
|
15
15
|
| Halt rotation on focus or hover | [Carousel pattern](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/): a rotating carousel "Stops rotating when keyboard focus enters the carousel" and "whenever the mouse is hovering over the carousel" | 2026-10-18, APG |
|
|
16
16
|
| Three flashes | [Understanding Three Flashes or Below Threshold](https://www.w3.org/WAI/WCAG22/Understanding/three-flashes-or-below-threshold.html): pages "do not contain anything that flashes more than three times in any one second period" | 2026-10-18, WCAG 2.2 |
|
|
17
17
|
|
|
18
|
+
## Motion the reader drives
|
|
19
|
+
|
|
20
|
+
| Bullet | Source | Read |
|
|
21
|
+
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
|
|
22
|
+
| Follow the input | [Shneiderman, Direct Manipulation, IEEE Computer 1983](https://www.cs.umd.edu/~ben/papers/Shneiderman1983Direct.pdf): direct manipulation rests on "Rapid, incremental, reversible operations whose impact on the object of interest is immediately visible." | 2026-11-08, IEEE Computer 1983 |
|
|
23
|
+
| Swap at a threshold | [Material Components Android, Motion](https://github.com/material-components/material-components-android/blob/master/docs/theming/Motion.md): the fade through variant will "sequentially fade out and in the outgoing and incoming views, respectively, in order to minimize the visual overlap of these views". Ruling out a blank frame is a House choice: a fade through dips both views toward zero at the seam, so a swap at the threshold keeps one text fully legible at every moment | 2026-11-08, Material |
|
|
24
|
+
| Hold the outer size | [Cumulative Layout Shift](https://web.dev/articles/cls): to animate without shifting layout, "Instead of changing the height and width properties, use transform: scale()" | 2026-11-08, web.dev |
|
|
25
|
+
|
|
18
26
|
## Reduced motion
|
|
19
27
|
|
|
20
28
|
| Bullet | Source | Read |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.80.0",
|
|
5
5
|
"description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
"astro": "^6.1.9",
|
|
76
76
|
"husky": "^9.1.7",
|
|
77
77
|
"micromatch": "^4.0.8",
|
|
78
|
-
"oxfmt": "0.
|
|
79
|
-
"oxlint": "1.
|
|
78
|
+
"oxfmt": "0.76.0",
|
|
79
|
+
"oxlint": "1.91.0",
|
|
80
80
|
"prettier": "^3.8.1",
|
|
81
81
|
"prettier-plugin-astro": "^0.14.1",
|
|
82
82
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
package/src/capture/render.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { basename, dirname } from 'node:path'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
4
|
import { chromium } from 'playwright-core'
|
|
5
5
|
import type { Browser, Locator, Page } from 'playwright-core'
|
|
6
|
+
import {
|
|
7
|
+
mergeBlocks,
|
|
8
|
+
preparePage,
|
|
9
|
+
revealSection,
|
|
10
|
+
splitBlocks,
|
|
11
|
+
} from '@/capture/sections'
|
|
6
12
|
import type { CaptureSource } from '@/capture/sources'
|
|
7
13
|
import {
|
|
8
14
|
isGenericFamily,
|
|
15
|
+
isUrlSource,
|
|
9
16
|
resolveCaptureSources,
|
|
10
17
|
textFamilies,
|
|
11
18
|
} from '@/capture/sources'
|
|
@@ -141,6 +148,148 @@ async function captureOne(
|
|
|
141
148
|
}
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
export interface SectionOptions {
|
|
152
|
+
outDir: string
|
|
153
|
+
root?: string
|
|
154
|
+
width?: number
|
|
155
|
+
colorScheme?: ColorScheme
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface SectionShot {
|
|
159
|
+
readonly index: number
|
|
160
|
+
readonly path: string
|
|
161
|
+
readonly top: number
|
|
162
|
+
readonly height: number
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Families the page draws text in that a shot cannot promise to reproduce,
|
|
167
|
+
* recorded rather than refused, since a reference shows a site as a browser
|
|
168
|
+
* draws it and most outside sites declare a generic family somewhere.
|
|
169
|
+
*/
|
|
170
|
+
export interface SectionFonts {
|
|
171
|
+
readonly generic: readonly string[]
|
|
172
|
+
readonly missing: readonly string[]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type SectionsResult =
|
|
176
|
+
| {
|
|
177
|
+
status: 'captured'
|
|
178
|
+
source: string
|
|
179
|
+
sections: SectionShot[]
|
|
180
|
+
failed: { index: number; reason: string }[]
|
|
181
|
+
fonts: SectionFonts
|
|
182
|
+
}
|
|
183
|
+
| { status: 'failed'; source: string; reason: string }
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Shoots one page as one PNG per section, in page order, each with its stamp.
|
|
187
|
+
* A section that fails to shoot is reported beside the rest rather than
|
|
188
|
+
* dropping them, and a page that never loads or names no root fails whole.
|
|
189
|
+
*
|
|
190
|
+
* Each shot is a document-coordinate clip rather than an element shot, since a
|
|
191
|
+
* merged section spans several elements and one element cannot frame them.
|
|
192
|
+
*/
|
|
193
|
+
export async function captureSections(
|
|
194
|
+
sourcePath: string,
|
|
195
|
+
{ outDir, root, width, colorScheme }: SectionOptions,
|
|
196
|
+
): Promise<SectionsResult> {
|
|
197
|
+
const pageSource: CaptureSource = isUrlSource(sourcePath)
|
|
198
|
+
? { kind: 'url', url: sourcePath, pngPath: outDir }
|
|
199
|
+
: { kind: 'file', htmlPath: sourcePath, pngPath: outDir }
|
|
200
|
+
const browser = await chromium.launch()
|
|
201
|
+
try {
|
|
202
|
+
const page = await browser.newPage({
|
|
203
|
+
deviceScaleFactor: DEVICE_SCALE_FACTOR,
|
|
204
|
+
...(width === undefined
|
|
205
|
+
? {}
|
|
206
|
+
: { viewport: { width, height: DEFAULT_VIEWPORT_HEIGHT } }),
|
|
207
|
+
...(colorScheme === undefined ? {} : { colorScheme }),
|
|
208
|
+
})
|
|
209
|
+
await page.goto(
|
|
210
|
+
pageSource.kind === 'url'
|
|
211
|
+
? pageSource.url
|
|
212
|
+
: pathToFileURL(pageSource.htmlPath).href,
|
|
213
|
+
)
|
|
214
|
+
if (colorScheme !== undefined) await applyColorScheme(page, colorScheme)
|
|
215
|
+
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
|
216
|
+
await preparePage(page)
|
|
217
|
+
|
|
218
|
+
const blocks = await splitBlocks(page, root)
|
|
219
|
+
if (blocks === null) {
|
|
220
|
+
return failed(pageSource, `no element matched ${root}`)
|
|
221
|
+
}
|
|
222
|
+
if (blocks.length === 0) {
|
|
223
|
+
return failed(
|
|
224
|
+
pageSource,
|
|
225
|
+
`${root ?? 'main, or body without one,'} drew no box to split into sections`,
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
const fonts = await unresolvedFonts(page)
|
|
229
|
+
|
|
230
|
+
mkdirSync(outDir, { recursive: true })
|
|
231
|
+
const shots: SectionShot[] = []
|
|
232
|
+
const misses: { index: number; reason: string }[] = []
|
|
233
|
+
for (const [offset, section] of mergeBlocks(blocks).entries()) {
|
|
234
|
+
const index = offset + 1
|
|
235
|
+
const pngPath = join(
|
|
236
|
+
outDir,
|
|
237
|
+
`section-${String(index).padStart(2, '0')}.png`,
|
|
238
|
+
)
|
|
239
|
+
try {
|
|
240
|
+
const clip = await revealSection(page, section)
|
|
241
|
+
if (clip === null) {
|
|
242
|
+
misses.push({ index, reason: 'the section drew no box' })
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
const png = await page.screenshot({ clip, fullPage: true })
|
|
246
|
+
writeFileSync(pngPath, png)
|
|
247
|
+
writeStamp({ ...pageSource, pngPath }, png)
|
|
248
|
+
shots.push({ index, path: pngPath, top: clip.y, height: clip.height })
|
|
249
|
+
} catch (error) {
|
|
250
|
+
misses.push({
|
|
251
|
+
index,
|
|
252
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
status: 'captured',
|
|
258
|
+
source: sourceIdentifier(pageSource),
|
|
259
|
+
sections: shots,
|
|
260
|
+
failed: misses,
|
|
261
|
+
fonts,
|
|
262
|
+
}
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return failed(
|
|
265
|
+
pageSource,
|
|
266
|
+
error instanceof Error ? error.message : String(error),
|
|
267
|
+
)
|
|
268
|
+
} finally {
|
|
269
|
+
await browser.close()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The generic and the absent families among those the page's text draws in,
|
|
275
|
+
* read once over the whole body, which is the probe single-element capture
|
|
276
|
+
* refuses on, turned into a record.
|
|
277
|
+
*/
|
|
278
|
+
async function unresolvedFonts(page: Page): Promise<SectionFonts> {
|
|
279
|
+
const declarations = await renderedTextFamilies(page.locator('body'))
|
|
280
|
+
const generic = textFamilies(declarations.filter(isGenericFamily))
|
|
281
|
+
const named = textFamilies(
|
|
282
|
+
declarations.filter((declaration) => !isGenericFamily(declaration)),
|
|
283
|
+
)
|
|
284
|
+
const resolved = await Promise.all(
|
|
285
|
+
named.map((family) => resolvesFont(page, family)),
|
|
286
|
+
)
|
|
287
|
+
return {
|
|
288
|
+
generic,
|
|
289
|
+
missing: named.filter((_, index) => !resolved[index]),
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
144
293
|
/**
|
|
145
294
|
* Runs inside the render rather than in a wrapper around it, so a capture
|
|
146
295
|
* cannot succeed and leave the provenance unrecorded. A throw here reaches the
|
|
@@ -202,7 +351,10 @@ async function applyColorScheme(
|
|
|
202
351
|
}, scheme)
|
|
203
352
|
}
|
|
204
353
|
|
|
205
|
-
function failed(
|
|
354
|
+
function failed(
|
|
355
|
+
source: CaptureSource,
|
|
356
|
+
reason: string,
|
|
357
|
+
): Extract<CaptureResult, { status: 'failed' }> {
|
|
206
358
|
return { status: 'failed', source: sourceIdentifier(source), reason }
|
|
207
359
|
}
|
|
208
360
|
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The split, the merge, and the page preparation behind `canon capture
|
|
5
|
+
* --sections`. The thresholds are the ones track 218 measured on Notion,
|
|
6
|
+
* Vercel, Linear, and ElevenLabs, and nothing beyond those four sites tuned
|
|
7
|
+
* them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A block taller than this many viewports splits into its children. */
|
|
11
|
+
const SPLIT_VIEWPORTS = 1.6
|
|
12
|
+
/** A child shorter than this is decoration rather than a block of its own. */
|
|
13
|
+
const MIN_CHILD_HEIGHT = 120
|
|
14
|
+
/** A child narrower than this share of the viewport is a column, not a band. */
|
|
15
|
+
const MIN_WIDTH_SHARE = 0.6
|
|
16
|
+
/** A block shorter than this joins the section above it. */
|
|
17
|
+
export const MERGE_BELOW_PX = 400
|
|
18
|
+
/** The tallest shot a section takes, in CSS pixels. Taller is cut. */
|
|
19
|
+
export const MAX_SECTION_PX = 2400
|
|
20
|
+
|
|
21
|
+
const SECTION_ATTRIBUTE = 'data-canon-section'
|
|
22
|
+
const WHEEL_STEP_PX = 600
|
|
23
|
+
const WHEEL_PAUSE_MS = 150
|
|
24
|
+
const SETTLE_MS = 800
|
|
25
|
+
const NETWORK_IDLE_MS = 5_000
|
|
26
|
+
|
|
27
|
+
/** One block the split found, in document coordinates and page order. */
|
|
28
|
+
export interface Block {
|
|
29
|
+
readonly index: number
|
|
30
|
+
readonly top: number
|
|
31
|
+
readonly height: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One or more adjacent blocks shot as one image. */
|
|
35
|
+
export interface Section {
|
|
36
|
+
readonly blocks: readonly number[]
|
|
37
|
+
readonly top: number
|
|
38
|
+
readonly height: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Loads what a reader would see by the time they reached each part of the
|
|
43
|
+
* page, then hides every fixed and sticky layer so none paints into the middle
|
|
44
|
+
* of a shot. Hiding is how a consent banner leaves, since a click on one
|
|
45
|
+
* decides something on the operator's behalf.
|
|
46
|
+
*
|
|
47
|
+
* Wheeling rather than `scrollTo`, because a lazy loader listening for wheel
|
|
48
|
+
* or for intersection sees each step, and a jump to the bottom skips them.
|
|
49
|
+
*/
|
|
50
|
+
export async function preparePage(page: Page): Promise<void> {
|
|
51
|
+
await page
|
|
52
|
+
.waitForLoadState('networkidle', { timeout: NETWORK_IDLE_MS })
|
|
53
|
+
.catch(() => undefined)
|
|
54
|
+
const height = await page.evaluate(
|
|
55
|
+
() => document.documentElement.scrollHeight,
|
|
56
|
+
)
|
|
57
|
+
for (let y = 0; y < height; y += WHEEL_STEP_PX) {
|
|
58
|
+
await page.mouse.wheel(0, WHEEL_STEP_PX)
|
|
59
|
+
await page.waitForTimeout(WHEEL_PAUSE_MS)
|
|
60
|
+
}
|
|
61
|
+
await page.evaluate(() => window.scrollTo(0, 0))
|
|
62
|
+
await page.waitForTimeout(SETTLE_MS)
|
|
63
|
+
await hideFixedLayers(page)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Runs again before every shot as well as once in preparation, since a consent
|
|
68
|
+
* banner commonly mounts on a timer after load and a single pass misses it.
|
|
69
|
+
* Starts at the root element rather than the body, since a consent manager
|
|
70
|
+
* can attach its host straight to `<html>`, and walks into open shadow roots,
|
|
71
|
+
* where such widgets mount and a document-level query never reaches. A closed
|
|
72
|
+
* root stays out of reach.
|
|
73
|
+
*/
|
|
74
|
+
async function hideFixedLayers(page: Page): Promise<void> {
|
|
75
|
+
await page.evaluate(() => {
|
|
76
|
+
const hide = (root: ParentNode): void => {
|
|
77
|
+
for (const element of root.querySelectorAll<HTMLElement>('*')) {
|
|
78
|
+
const position = getComputedStyle(element).position
|
|
79
|
+
if (position === 'fixed' || position === 'sticky') {
|
|
80
|
+
element.style.setProperty('visibility', 'hidden', 'important')
|
|
81
|
+
}
|
|
82
|
+
if (element.shadowRoot) hide(element.shadowRoot)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
hide(document.documentElement)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Walks down from the root, splitting any block taller than the split
|
|
91
|
+
* threshold into its full-width children, and tags each block it keeps so a
|
|
92
|
+
* later step can find it again. Null when the root selector matched nothing.
|
|
93
|
+
*
|
|
94
|
+
* With no root named, `main` is tried and then `body`, since track 218 found
|
|
95
|
+
* `main` on every site it tried and a page without one still has a body.
|
|
96
|
+
*/
|
|
97
|
+
export async function splitBlocks(
|
|
98
|
+
page: Page,
|
|
99
|
+
root: string | undefined,
|
|
100
|
+
): Promise<Block[] | null> {
|
|
101
|
+
return page.evaluate(
|
|
102
|
+
({ root, attribute, splitViewports, minChildHeight, minWidthShare }) => {
|
|
103
|
+
const start = root
|
|
104
|
+
? document.querySelector(root)
|
|
105
|
+
: (document.querySelector('main') ?? document.body)
|
|
106
|
+
if (!start) return null
|
|
107
|
+
|
|
108
|
+
const viewportWidth = window.innerWidth
|
|
109
|
+
const viewportHeight = window.innerHeight
|
|
110
|
+
const measure = (element: Element) => {
|
|
111
|
+
const rect = element.getBoundingClientRect()
|
|
112
|
+
return {
|
|
113
|
+
element,
|
|
114
|
+
top: Math.round(rect.top + window.scrollY),
|
|
115
|
+
height: Math.round(rect.height),
|
|
116
|
+
width: Math.round(rect.width),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
type Measured = ReturnType<typeof measure>
|
|
120
|
+
const split = (element: Element): Measured[] => {
|
|
121
|
+
const box = measure(element)
|
|
122
|
+
if (box.height <= viewportHeight * splitViewports) return [box]
|
|
123
|
+
const children = [...element.children]
|
|
124
|
+
.map(measure)
|
|
125
|
+
.filter(
|
|
126
|
+
(child) =>
|
|
127
|
+
child.height >= minChildHeight &&
|
|
128
|
+
child.width >= viewportWidth * minWidthShare,
|
|
129
|
+
)
|
|
130
|
+
if (children.length === 0) return [box]
|
|
131
|
+
return children.flatMap((child) => split(child.element))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return split(start)
|
|
135
|
+
.filter((box) => box.height > 0)
|
|
136
|
+
.map((box, index) => {
|
|
137
|
+
box.element.setAttribute(attribute, String(index))
|
|
138
|
+
return { index, top: box.top, height: box.height }
|
|
139
|
+
})
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
root,
|
|
143
|
+
attribute: SECTION_ATTRIBUTE,
|
|
144
|
+
splitViewports: SPLIT_VIEWPORTS,
|
|
145
|
+
minChildHeight: MIN_CHILD_HEIGHT,
|
|
146
|
+
minWidthShare: MIN_WIDTH_SHARE,
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Joins each block shorter than the merge threshold to the section above it,
|
|
153
|
+
* so a page built of thin bands comes back as the sections a person would
|
|
154
|
+
* name. A short first block has nothing above it and joins the one below.
|
|
155
|
+
*
|
|
156
|
+
* A merge that would carry a section past the shot cap starts a new section
|
|
157
|
+
* instead, so a run of bands under a tall block is not cut off by the cap.
|
|
158
|
+
*/
|
|
159
|
+
export function mergeBlocks(
|
|
160
|
+
blocks: readonly Block[],
|
|
161
|
+
minHeight: number = MERGE_BELOW_PX,
|
|
162
|
+
maxHeight: number = MAX_SECTION_PX,
|
|
163
|
+
): Section[] {
|
|
164
|
+
const sections: Section[] = []
|
|
165
|
+
let leading: Block[] = []
|
|
166
|
+
for (const block of blocks) {
|
|
167
|
+
const last = sections.at(-1)
|
|
168
|
+
if (last === undefined) {
|
|
169
|
+
if (leading.length && spanOf([...leading, block]).height > maxHeight) {
|
|
170
|
+
sections.push(spanOf(leading))
|
|
171
|
+
leading = []
|
|
172
|
+
}
|
|
173
|
+
leading.push(block)
|
|
174
|
+
const span = spanOf(leading)
|
|
175
|
+
if (span.height >= minHeight) {
|
|
176
|
+
sections.push(span)
|
|
177
|
+
leading = []
|
|
178
|
+
}
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
const merged = spanOf([last, block])
|
|
182
|
+
if (block.height < minHeight && merged.height <= maxHeight) {
|
|
183
|
+
sections[sections.length - 1] = {
|
|
184
|
+
...merged,
|
|
185
|
+
blocks: [...last.blocks, block.index],
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
sections.push(spanOf([block]))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (leading.length) sections.push(spanOf(leading))
|
|
192
|
+
return sections
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function spanOf(parts: readonly (Block | Section)[]): Section {
|
|
196
|
+
const top = Math.min(...parts.map((part) => part.top))
|
|
197
|
+
const bottom = Math.max(...parts.map((part) => part.top + part.height))
|
|
198
|
+
const blocks = parts.flatMap((part) =>
|
|
199
|
+
'blocks' in part ? part.blocks : [part.index],
|
|
200
|
+
)
|
|
201
|
+
return { blocks, top, height: bottom - top }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The clip a section's shot takes, measured again after it scrolled in. */
|
|
205
|
+
export interface Clip {
|
|
206
|
+
readonly x: number
|
|
207
|
+
readonly y: number
|
|
208
|
+
readonly width: number
|
|
209
|
+
readonly height: number
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Scrolls a section's first block into view so a reveal-on-view block paints,
|
|
214
|
+
* waits for it to settle, then measures the union of its blocks in document
|
|
215
|
+
* coordinates, cut at the shot cap. Measured after the scroll rather than
|
|
216
|
+
* taken from the split, since content loading above can move it.
|
|
217
|
+
*/
|
|
218
|
+
export async function revealSection(
|
|
219
|
+
page: Page,
|
|
220
|
+
section: Section,
|
|
221
|
+
): Promise<Clip | null> {
|
|
222
|
+
const first = section.blocks[0]
|
|
223
|
+
if (first === undefined) return null
|
|
224
|
+
await page
|
|
225
|
+
.locator(`[${SECTION_ATTRIBUTE}="${first}"]`)
|
|
226
|
+
.scrollIntoViewIfNeeded()
|
|
227
|
+
.catch(() => undefined)
|
|
228
|
+
await page.waitForTimeout(SETTLE_MS)
|
|
229
|
+
await hideFixedLayers(page)
|
|
230
|
+
return page.evaluate(
|
|
231
|
+
({ attribute, blocks, cap }) => {
|
|
232
|
+
const rects = blocks
|
|
233
|
+
.map((index) => document.querySelector(`[${attribute}="${index}"]`))
|
|
234
|
+
.filter((element): element is Element => element !== null)
|
|
235
|
+
.map((element) => element.getBoundingClientRect())
|
|
236
|
+
if (!rects.length) return null
|
|
237
|
+
const left = Math.min(...rects.map((rect) => rect.left))
|
|
238
|
+
const right = Math.max(...rects.map((rect) => rect.right))
|
|
239
|
+
const top = Math.min(...rects.map((rect) => rect.top))
|
|
240
|
+
const bottom = Math.max(...rects.map((rect) => rect.bottom))
|
|
241
|
+
const width = Math.round(right - left)
|
|
242
|
+
const height = Math.round(Math.min(bottom - top, cap))
|
|
243
|
+
if (width <= 0 || height <= 0) return null
|
|
244
|
+
return {
|
|
245
|
+
x: Math.max(0, Math.round(left + window.scrollX)),
|
|
246
|
+
y: Math.max(0, Math.round(top + window.scrollY)),
|
|
247
|
+
width,
|
|
248
|
+
height,
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
attribute: SECTION_ATTRIBUTE,
|
|
253
|
+
blocks: section.blocks,
|
|
254
|
+
cap: MAX_SECTION_PX,
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
}
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -26,6 +26,11 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
26
26
|
'Show me a few different treatments for this callout so I can pick one by looking.',
|
|
27
27
|
expect: 'design-pick',
|
|
28
28
|
},
|
|
29
|
+
{
|
|
30
|
+
prompt:
|
|
31
|
+
'Before we draft the landing page, pull references from linear.app so the round has a real site to look at.',
|
|
32
|
+
expect: 'design-references',
|
|
33
|
+
},
|
|
29
34
|
{
|
|
30
35
|
prompt:
|
|
31
36
|
'Which layer of this landing page should I settle first, and what has to stay constant across it?',
|
|
@@ -35,6 +35,13 @@ export const SETUP_CASES: readonly SkillCase[] = [
|
|
|
35
35
|
'Check that the dev server actually starts and the end-to-end suite passes against the scaffold.',
|
|
36
36
|
expect: 'target-setup',
|
|
37
37
|
},
|
|
38
|
+
// An empty folder has no files for setup's detection to read, which is what
|
|
39
|
+
// separates starting a project from bootstrapping one that already exists.
|
|
40
|
+
{
|
|
41
|
+
prompt:
|
|
42
|
+
'This folder is empty, start a new astro project in it and get it on the toolkit.',
|
|
43
|
+
expect: 'target-scaffold',
|
|
44
|
+
},
|
|
38
45
|
{
|
|
39
46
|
prompt:
|
|
40
47
|
"Before I run this sync, tell me exactly what it's going to overwrite.",
|