@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/canon-cli/SKILL.md +13 -10
  4. package/claude/skills/design-pick/SKILL.md +8 -0
  5. package/claude/skills/design-pick/references/design-config.md +9 -6
  6. package/claude/skills/design-references/REQUIREMENT.md +32 -0
  7. package/claude/skills/design-references/SKILL.md +120 -0
  8. package/claude/skills/design-references/references/films.md +90 -0
  9. package/claude/skills/design-references/references/sites.md +36 -0
  10. package/claude/skills/design-taste/SKILL.md +16 -4
  11. package/claude/skills/design-taste/references/adopted.md +5 -1
  12. package/claude/skills/design-taste/references/kinds.md +1 -0
  13. package/claude/skills/design-taste/references/preflight.md +27 -1
  14. package/claude/skills/design-taste/references/tells.md +2 -0
  15. package/claude/skills/review-ui/references/driving.md +33 -1
  16. package/claude/skills/search-craft/REQUIREMENT.md +3 -3
  17. package/claude/skills/search-craft/SKILL.md +12 -1
  18. package/claude/skills/target-scaffold/REQUIREMENT.md +31 -0
  19. package/claude/skills/target-scaffold/SKILL.md +44 -0
  20. package/claude/skills/target-setup/SKILL.md +5 -1
  21. package/claude/skills/ui-checklist/SKILL.md +5 -1
  22. package/claude/skills/ui-craft/SKILL.md +9 -9
  23. package/claude/skills/ui-craft/references/adopted.md +4 -2
  24. package/claude/skills/ui-craft/references/capture.md +8 -0
  25. package/claude/skills/ui-craft/references/controls.md +6 -0
  26. package/claude/skills/ui-craft/references/motion.md +6 -0
  27. package/claude/skills/ui-film/references/take-craft.md +2 -0
  28. package/docs/agents/output-shape.md +2 -1
  29. package/docs/target/projects.md +2 -2
  30. package/docs/workflow/skill-map.md +7 -5
  31. package/docs/workflow/visual-design-workflow.md +4 -0
  32. package/governance/ledger/skills/design-references/SKILL.md +38 -0
  33. package/governance/ledger/skills/design-taste/SKILL.md +36 -30
  34. package/governance/ledger/skills/design-taste/kinds.md +1 -0
  35. package/governance/ledger/skills/design-taste/tells.md +22 -20
  36. package/governance/ledger/skills/search-craft/SKILL.md +6 -0
  37. package/governance/ledger/skills/ui-craft/capture.md +10 -0
  38. package/governance/ledger/skills/ui-craft/controls.md +8 -0
  39. package/governance/ledger/skills/ui-craft/motion.md +9 -1
  40. package/package.json +3 -3
  41. package/src/capture/render.ts +154 -2
  42. package/src/capture/sections.ts +257 -0
  43. package/src/claude/cases/misc.ts +5 -0
  44. package/src/claude/cases/setup.ts +7 -0
  45. package/src/commands/capture.ts +149 -4
  46. package/src/commands/tooling.ts +72 -0
  47. package/src/take/perform.ts +29 -4
  48. package/src/tooling/list.ts +3 -0
  49. package/src/tooling/scaffold.ts +193 -0
  50. package/tooling/cloudflare/manifest.toml +1 -1
  51. 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 | 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
- | 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 |
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 | Read |
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" | 2026-10-24 |
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" | 2026-10-24 |
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" | 2026-10-24 |
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." | 2026-10-24 |
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" | 2026-10-24 |
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." | 2026-10-24 |
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'" | 2026-10-24 |
43
- | 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 |
44
- | 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 |
45
- | 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 |
46
- | 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 |
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 field is web accessibility, and the authorities are the W3C, through WCAG 2.2 and the ARIA Authoring Practices Guide, and MDN for the media feature.
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.79.0",
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.75.0",
79
- "oxlint": "1.90.0",
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",
@@ -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(source: CaptureSource, reason: string): CaptureResult {
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
+ }
@@ -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.",