@domandigital/craft 0.10.0 → 0.12.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 +35 -0
- package/CHARACTER.md +9 -1
- package/COPY.md +6 -2
- package/README.md +40 -0
- package/dist/{audit-EGDNWSQB.js → audit-5QWFLZ6L.js} +6 -3
- package/dist/audit.cjs +279 -16
- package/dist/audit.d.cts +81 -4
- package/dist/audit.d.ts +81 -4
- package/dist/audit.js +278 -16
- package/dist/{chunk-3NGWXZZE.js → chunk-AL6MRG6V.js} +40 -3
- package/dist/{chunk-2NQFIDLE.js → chunk-CDAJUS5U.js} +13 -11
- package/dist/{chunk-JKIDWHG3.js → chunk-J3QUROJ5.js} +299 -32
- package/dist/chunk-JYNXGCYZ.js +75 -0
- package/dist/{chunk-APO4BT2F.js → chunk-LHGY43LL.js} +11 -7
- package/dist/chunk-PU5G5DUC.js +206 -0
- package/dist/{chunk-QMYERCBB.js → chunk-PZ4MF2VV.js} +19 -7
- package/dist/{chunk-JWHC67ZS.js → chunk-S3NZAVU6.js} +121 -1
- package/dist/{chunk-TSKRBIOC.js → chunk-ULPI335S.js} +181 -13
- package/dist/chunk-ZKVJ7K6X.js +19 -0
- package/dist/cli-2DY3V3SF.js +17 -0
- package/dist/{cli-PRWTNFVP.js → cli-C3JLFXMG.js} +5 -4
- package/dist/cli-QM2AQQMF.js +19 -0
- package/dist/{cli-ADQQURNR.js → cli-ZB2VJ7CY.js} +25 -17
- package/dist/{cli-2WBGTBKD.js → cli-ZPJIWVEP.js} +15 -80
- package/dist/cli.js +2 -2
- package/dist/index.cjs +486 -9
- package/dist/index.d.cts +364 -6
- package/dist/index.d.ts +364 -6
- package/dist/index.js +462 -9
- package/licences.json +104 -0
- package/package.json +15 -7
- package/word-lists.json +1 -1
- package/dist/chunk-U2JTFQKO.js +0 -112
- package/dist/cli-EK3GSAOW.js +0 -15
- package/dist/cli-VQUNNIZM.js +0 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# @domandigital/craft
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7622bd9: Phase K: snapshot version 2.
|
|
8
|
+
|
|
9
|
+
- Every section gains a `role`: `cta-band`, `footer-cta`, `pricing`, `testimonials`, `faq`, `process`, `features`, `team`, `contact`, or its `kind`. `kind` keeps its version 1 meaning, so the rendered tells and the calibrated null models and estate read exactly as before.
|
|
10
|
+
- Every section gains `geometry`: `centredShare`, `mirrorSymmetry`, `whitespaceRatio`, `contentWidthRatio`, `background` and `controls`. The page gains `rhythmVariance` (how much section heights vary).
|
|
11
|
+
- `visual`: colourfulness (Hasler and Süsstrunk), edge density and left-right symmetry, from a screenshot of the first 6000px. None of them passes or fails anything. `snapshotUrl(url, { visual: false })` skips the screenshot; a driver without `page.screenshot` gets no `visual`.
|
|
12
|
+
- `SNAPSHOT_VERSION` is 2. `readSnapshot()` reads version 1 files as version 2 marked `migratedFrom: 1`; every command that loads a saved snapshot uses it, so old snapshots still work.
|
|
13
|
+
- `FINGERPRINT_VERSION` is 2: fingerprints carry `sections` (role, centring, width), and `layoutDistance` compares by role and geometry when both sides have them. Against a version 1 fingerprint it compares by `kind`, exactly as before.
|
|
14
|
+
- New exports: `readSnapshot`, `READABLE_SNAPSHOT_VERSIONS`, `visualMeasures`, `EDGE_STEP`, `layoutDistance`, and the types `SectionRole`, `SectionGeometry`, `SnapshotVisual`, `FingerprintSection`, `RgbaImage`.
|
|
15
|
+
|
|
16
|
+
- 07d1cf5: From the 2026-09-11 AI-look research. A new tell, `shadcn-card-stock`: the registry Card recipe left unchanged (`warn`). Cal Sans joins the second-wave reflex fonts. The icon-tile tells now see Unicons, MUI icons and Material Symbols as well as Lucide, Heroicons, Phosphor and Tabler. `craft direction validate` warns when a display or body face is missing from the new licence register, or is capped, per-site or unverified there. The register ships as `licences.json` and as `LICENCES` and `faceLicence()`. Catalogue `2026.09.9`.
|
|
17
|
+
- c63cded: **Behaviour change:** under `craft copy --gate`, a repo can no longer lower the house blocking tier. A `warn` or `off` in `craft.config.json`, or an `art-direction.json` exception, naming a blocking tell (em dash, contrastive negation, chatbot residue and the rest) is refused and listed as "Exception not applied". Raising a tell still works, runs without `--gate` are unchanged, and a genuine one-off still takes `copy-ok` on its line. `holdHouseBlocks` and `HOUSE_LOCKED` are exported from the house policy.
|
|
18
|
+
|
|
19
|
+
## 0.11.0
|
|
20
|
+
|
|
21
|
+
### Minor Changes
|
|
22
|
+
|
|
23
|
+
- e2ea0e8: Phase I: craft in CI.
|
|
24
|
+
|
|
25
|
+
- `craft.config.json`: `ignore` globs, `copyPaths` (where `craft copy` looks when given no path), and `severity` changes per tell. Every severity change needs a `because` of at least a sentence; `off` is applied as an exception, so the report still lists what it silenced. `--config <file>` points elsewhere.
|
|
26
|
+
- `.claude`, `.agents` and `.cursor` are never walked (a path named on the command line is still read).
|
|
27
|
+
- `--baseline <file>` reports only findings the baseline does not hold; `--update-baseline` writes it. Findings are keyed on tell, path and excerpt, not line, and counted.
|
|
28
|
+
- `--sarif <file>` writes SARIF 2.1.0 for GitHub code scanning.
|
|
29
|
+
- Every `--json` output carries `schemaVersion: 1`. The audit JSON adds `pages` and `failed`.
|
|
30
|
+
- `craft audit --pages <sitemap.xml | urls.txt>` and `--viewport 390,768,1440`. A page that will not load is listed as not measured and fails `--strict`.
|
|
31
|
+
- **Behaviour change:** a page that answers with an HTTP error (404, 500) is no longer snapshotted as if it were the page. `snapshotUrls` reports it as an error and `snapshotUrl` throws `HTTP <status>`. Before, a missing page was measured, and reported clean.
|
|
32
|
+
- New exports: `createBaseline`, `applyBaseline`, `parseBaseline`, `findingKey`, `parseCraftConfig`, `applySeverity`, `severityExceptions`, `globToRegExp`, `ignoreMatcher`, `DEFAULT_IGNORE`, `toSarif`, `toJson`, `JSON_SCHEMA_VERSION`.
|
|
33
|
+
|
|
34
|
+
- 3eb1560: CommonJS consumers now get the CommonJS type declarations. The `exports` map put a top-level `types` (the ESM `.d.ts`) ahead of `require`, so TypeScript resolving a `require` under `node16` stopped at the ESM file ("Masquerading as ESM" in @arethetypeswrong/cli). `types` now sits inside `import` and `require` separately. Runtime behaviour is unchanged.
|
|
35
|
+
|
|
36
|
+
Requires Node 22.12 or later (`engines.node`). Node 20 reached end of life on 2026-04-30. Also declares `sideEffects` so bundlers can tree-shake (craft keeps its CSS).
|
|
37
|
+
|
|
3
38
|
## 0.10.0
|
|
4
39
|
|
|
5
40
|
### Minor Changes
|
package/CHARACTER.md
CHANGED
|
@@ -88,6 +88,13 @@ clean, premium, elegant and the rest), and a value on the tell catalogue
|
|
|
88
88
|
without an exception carrying the same reason. Given a snapshot, it warns
|
|
89
89
|
where the page does not show what the file declares.
|
|
90
90
|
|
|
91
|
+
It also reads the display and body faces against the licence register
|
|
92
|
+
(`src/direction/licences.ts`, exported as `licences.json`) and warns on a face
|
|
93
|
+
the register does not know, or knows only as capped, per-site or unverified. A
|
|
94
|
+
licence warning does not undecide the choice: the reason can be sound and the
|
|
95
|
+
face still unlicensed for this client. The register records facts, checked on
|
|
96
|
+
the licensor's own page. It is not a shortlist, and nothing proposes from it.
|
|
97
|
+
|
|
91
98
|
`craft direction init` writes what the site does today with every reason
|
|
92
99
|
empty, so the first thing it produces is the list of things nobody decided.
|
|
93
100
|
Run on RMP's live home page (2026-09-23) it records Fraunces, Manrope, a cream
|
|
@@ -534,7 +541,7 @@ Generated from the package. Run `pnpm --filter @domandigital/craft run docs`
|
|
|
534
541
|
after changing an entry; a test fails until you do.
|
|
535
542
|
|
|
536
543
|
<!-- craft:catalogue:start -->
|
|
537
|
-
Catalogue version `2026.09.
|
|
544
|
+
Catalogue version `2026.09.9`, 56 tells.
|
|
538
545
|
|
|
539
546
|
| Id | Gen | Surface | Severity | Tell | Why it is a default |
|
|
540
547
|
| --- | --- | --- | --- | --- | --- |
|
|
@@ -549,6 +556,7 @@ Catalogue version `2026.09.8`, 55 tells.
|
|
|
549
556
|
| `reveal-everywhere` | 1 | source + rendered | warn | Reveal on every section | When every block fades up on scroll, the motion stops meaning anything and the page feels slow. MMM had 70, DD 40, sen-sphere 27. |
|
|
550
557
|
| `pill-everything` | 1 | source + rendered | warn | Pills everywhere | rounded-full on every button, badge and tag is the framework's friendliest default. MMM had 162, DD 125. A shape used everywhere is not a shape language. |
|
|
551
558
|
| `shadcn-dump` | 1 | source | warn | Stock component dump | A generator exports the whole shadcn/ui kit whether the site uses it or not. Rise & Bloom ships a full set under its own book, vine and petal assets. |
|
|
559
|
+
| `shadcn-card-stock` | 1 | source | warn | Stock shadcn Card | The registry Card (bg-card, a plain border, rounded-lg or rounded-xl, shadow-sm) is the container every generated page is built from. Left as shipped, every card on the site looks like every other shadcn site's. |
|
|
552
560
|
| `cream-palette` | 2 | source + rendered | warn | Cream ground | Once white-and-violet was named, models moved to a warm off-white: parchment, linen, oat. It reads as 'tasteful' in the same way everywhere. |
|
|
553
561
|
| `italic-serif-display` | 2 | source + rendered | warn | Italic serif display | A serif heading with one italic word ('Beauty, reimagined') is the second wave's signature move. |
|
|
554
562
|
| `hero-eyebrow-chip` | 2 | source + rendered | warn | Eyebrow chip above the hero | A small bordered pill ('New · Now booking') above the headline is in almost every generated hero. |
|
package/COPY.md
CHANGED
|
@@ -256,8 +256,12 @@ For genuine exceptions only, and the reason matters more than the mechanism:
|
|
|
256
256
|
- `.copyauditignore` at the repo root: globs, matched on the repo-relative
|
|
257
257
|
path and the bare filename.
|
|
258
258
|
- `.nocopycheck` at the repo root: skips the repo.
|
|
259
|
-
- An `exceptions` entry in `art-direction.json`,
|
|
260
|
-
a sentence.
|
|
259
|
+
- An `exceptions` entry in `art-direction.json`, or a `severity` entry in
|
|
260
|
+
`craft.config.json`, with a `because` of at least a sentence. **Never for
|
|
261
|
+
the blocking tier.** Under `craft copy --gate` a repo can raise a rule but
|
|
262
|
+
not lower one that blocks: the gate refuses the entry and says so. A
|
|
263
|
+
genuine one-off in the blocking tier takes `copy-ok` on its line, where a
|
|
264
|
+
reviewer sees it.
|
|
261
265
|
|
|
262
266
|
Reaching for these more than occasionally means a rule is miscalibrated. Fix
|
|
263
267
|
it here and in craft, not in forty repos.
|
package/README.md
CHANGED
|
@@ -94,6 +94,46 @@ reason, in `art-direction.json`:
|
|
|
94
94
|
{ "exceptions": [{ "tell": "ai-violet", "because": "Violet is on the van, the cards and the fascia." }] }
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
### In CI
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npx craft scan --baseline craft-baseline.json --update-baseline # once: adopt today's findings
|
|
101
|
+
npx craft scan --baseline craft-baseline.json --strict --sarif craft.sarif
|
|
102
|
+
npx craft audit --pages https://example.com/sitemap.xml --viewport 390,1440
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `--baseline` reports only findings the baseline does not already hold. A
|
|
106
|
+
finding is keyed on its tell, path and excerpt, never its line, so an edit
|
|
107
|
+
above a known finding does not make it new. The output says how many known
|
|
108
|
+
findings it left out.
|
|
109
|
+
- `--sarif <file>` writes SARIF 2.1.0 for GitHub code scanning. Findings on a
|
|
110
|
+
rendered page carry the URL, not a file, so code scanning does not show them.
|
|
111
|
+
- `--json` output always carries `schemaVersion` (currently 1).
|
|
112
|
+
- `--pages` audits every URL in a sitemap (or a sitemap index, one level deep)
|
|
113
|
+
or a file of URLs; `--viewport` audits each at those widths. A page that
|
|
114
|
+
will not load is listed as not measured, and fails `--strict`.
|
|
115
|
+
|
|
116
|
+
`craft.config.json` in the repo root holds what craft should know about the
|
|
117
|
+
repo. `.claude`, `.agents` and `.cursor` are never walked, config or not.
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"ignore": ["docs/archive/**", "*.stories.tsx"],
|
|
122
|
+
"copyPaths": ["content", "src/app"],
|
|
123
|
+
"severity": {
|
|
124
|
+
"em-dash": { "level": "block", "because": "This client's style guide bans them outright." }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A severity change needs a `because`, like an exception. `off` is applied as an
|
|
130
|
+
exception, so the report lists what it silenced.
|
|
131
|
+
|
|
132
|
+
Under `craft copy --gate`, a repo can raise a copy tell but never lower one in
|
|
133
|
+
the house blocking tier. A `warn` or `off` for a blocking tell, here or as an
|
|
134
|
+
exception in `art-direction.json`, is refused and listed in the report. A
|
|
135
|
+
genuine one-off takes `copy-ok` on its line.
|
|
136
|
+
|
|
97
137
|
### Copy
|
|
98
138
|
|
|
99
139
|
[COPY.md](./COPY.md) is the house copy standard: the blocking tier, the review
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
VISUAL_MAX_HEIGHT,
|
|
2
3
|
audit,
|
|
3
4
|
collectInPage,
|
|
4
5
|
loadPlaywright,
|
|
@@ -6,10 +7,12 @@ import {
|
|
|
6
7
|
snapshotPage,
|
|
7
8
|
snapshotUrl,
|
|
8
9
|
snapshotUrls
|
|
9
|
-
} from "./chunk-
|
|
10
|
-
import "./chunk-
|
|
11
|
-
import "./chunk-
|
|
10
|
+
} from "./chunk-ULPI335S.js";
|
|
11
|
+
import "./chunk-JYNXGCYZ.js";
|
|
12
|
+
import "./chunk-PZ4MF2VV.js";
|
|
13
|
+
import "./chunk-AL6MRG6V.js";
|
|
12
14
|
export {
|
|
15
|
+
VISUAL_MAX_HEIGHT,
|
|
13
16
|
audit,
|
|
14
17
|
collectInPage,
|
|
15
18
|
loadPlaywright,
|
package/dist/audit.cjs
CHANGED
|
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/audit/index.ts
|
|
21
21
|
var audit_exports = {};
|
|
22
22
|
__export(audit_exports, {
|
|
23
|
+
VISUAL_MAX_HEIGHT: () => VISUAL_MAX_HEIGHT,
|
|
23
24
|
audit: () => audit,
|
|
24
25
|
collectInPage: () => collectInPage,
|
|
25
26
|
loadPlaywright: () => loadPlaywright,
|
|
@@ -1567,6 +1568,7 @@ var REFLEX_FONTS_2 = [
|
|
|
1567
1568
|
"Plus Jakarta Sans",
|
|
1568
1569
|
"Outfit",
|
|
1569
1570
|
"Geist",
|
|
1571
|
+
"Cal Sans",
|
|
1570
1572
|
"Sora",
|
|
1571
1573
|
"Instrument Sans",
|
|
1572
1574
|
"Instrument Serif",
|
|
@@ -1788,7 +1790,7 @@ function iconTiles(file) {
|
|
|
1788
1790
|
return sized && has(unit, /^rounded-(?:md|lg|xl|2xl|full)$/) && has(unit, /^(?:items-center|place-items-center|place-content-center)$/) && has(unit, /^bg-/);
|
|
1789
1791
|
});
|
|
1790
1792
|
}
|
|
1791
|
-
var ICON_LIBRARY = /from\s+["'](?:lucide-react|@heroicons\/react[^"']*|react-icons[^"']*|@phosphor-icons\/react|@tabler\/icons-react|@radix-ui\/react-icons)["']/;
|
|
1793
|
+
var ICON_LIBRARY = /from\s+["'](?:lucide-react|@heroicons\/react[^"']*|react-icons[^"']*|@phosphor-icons\/react|@tabler\/icons-react|@radix-ui\/react-icons|@iconscout\/react-unicons[^"']*|@mui\/icons-material[^"']*|react-material-symbols[^"']*)["']/;
|
|
1792
1794
|
function iconTileStackHits(ctx) {
|
|
1793
1795
|
const hits = [];
|
|
1794
1796
|
for (const file of ctx.files) {
|
|
@@ -1935,6 +1937,12 @@ function shadcnHits(ctx) {
|
|
|
1935
1937
|
const detail = unused.length > 0 ? `; ${unused.length} imported nowhere` : "";
|
|
1936
1938
|
return [{ path: ui[0].path, offset: 0, message: `${ui.length} stock shadcn primitives${detail}` }];
|
|
1937
1939
|
}
|
|
1940
|
+
function shadcnCardHits(ctx) {
|
|
1941
|
+
return eachUnit(
|
|
1942
|
+
ctx,
|
|
1943
|
+
(unit) => has(unit, /^bg-card$/) && has(unit, /^text-card-foreground$/) && has(unit, /^border$/) && has(unit, /^rounded-(?:lg|xl)$/) && has(unit, /^shadow(?:-sm)?$/) ? "the registry Card recipe, unchanged" : null
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1938
1946
|
function marqueeHits(ctx) {
|
|
1939
1947
|
const hits = [];
|
|
1940
1948
|
const pattern = /@keyframes\s+[\w-]*marquee|animate-(?:marquee|scroll-x|infinite-scroll)\b|<Marquee\b|["']react-fast-marquee["']|ui\/marquee["']|animation(?:-name)?\s*:\s*[^;]*marquee/i;
|
|
@@ -2152,6 +2160,23 @@ const inter = Inter({ subsets: ["latin"] });`),
|
|
|
2152
2160
|
pass: [f3("src/components/ui/button.tsx", `export function Button() { return null; }`)]
|
|
2153
2161
|
}
|
|
2154
2162
|
},
|
|
2163
|
+
{
|
|
2164
|
+
id: "shadcn-card-stock",
|
|
2165
|
+
name: "Stock shadcn Card",
|
|
2166
|
+
generation: 1,
|
|
2167
|
+
severity: "warn",
|
|
2168
|
+
surface: "source",
|
|
2169
|
+
why: "The registry Card (bg-card, a plain border, rounded-lg or rounded-xl, shadow-sm) is the container every generated page is built from. Left as shipped, every card on the site looks like every other shadcn site's.",
|
|
2170
|
+
fix: "Give the card this site's radius and shadow from the restraint budget, or drop the shadow and let a visible border carry it.",
|
|
2171
|
+
detect: shadcnCardHits,
|
|
2172
|
+
fixtures: {
|
|
2173
|
+
flag: [
|
|
2174
|
+
f3("components/ui/card.tsx", `className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}`),
|
|
2175
|
+
f3("components/ui/card.tsx", `className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}`)
|
|
2176
|
+
],
|
|
2177
|
+
pass: [f3("components/ui/card.tsx", `className={cn("rounded-sm border-2 border-ink bg-card text-card-foreground", className)}`)]
|
|
2178
|
+
}
|
|
2179
|
+
},
|
|
2155
2180
|
{
|
|
2156
2181
|
id: "cream-palette",
|
|
2157
2182
|
name: "Cream ground",
|
|
@@ -2395,7 +2420,7 @@ function excerptAt(text, offset, max = 140) {
|
|
|
2395
2420
|
}
|
|
2396
2421
|
|
|
2397
2422
|
// src/snapshot/types.ts
|
|
2398
|
-
var SNAPSHOT_VERSION =
|
|
2423
|
+
var SNAPSHOT_VERSION = 2;
|
|
2399
2424
|
|
|
2400
2425
|
// src/snapshot/fixture.ts
|
|
2401
2426
|
function makeSnapshot(patch = {}) {
|
|
@@ -2729,7 +2754,7 @@ var RENDERED_PATHS = {
|
|
|
2729
2754
|
};
|
|
2730
2755
|
|
|
2731
2756
|
// src/character/check.ts
|
|
2732
|
-
var CATALOGUE_VERSION = "2026.09.
|
|
2757
|
+
var CATALOGUE_VERSION = "2026.09.9";
|
|
2733
2758
|
var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
|
|
2734
2759
|
(t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
|
|
2735
2760
|
);
|
|
@@ -2827,7 +2852,7 @@ function auditSnapshot(snapshot, options = {}) {
|
|
|
2827
2852
|
}
|
|
2828
2853
|
|
|
2829
2854
|
// src/fingerprint/index.ts
|
|
2830
|
-
var FINGERPRINT_VERSION =
|
|
2855
|
+
var FINGERPRINT_VERSION = 2;
|
|
2831
2856
|
var CHROMATIC = 0.03;
|
|
2832
2857
|
var MIN_ACCENT_AREA = 2e4;
|
|
2833
2858
|
var oklch = (value) => {
|
|
@@ -2882,7 +2907,8 @@ function fingerprint(s) {
|
|
|
2882
2907
|
roundness: median(s.controls.map((c) => Math.min(0.5, c.radiusPx / Math.max(1, c.heightPx)))),
|
|
2883
2908
|
motion: below ? Math.min(1, s.motion.hiddenSections / below) : 0,
|
|
2884
2909
|
effects: effects.sort(),
|
|
2885
|
-
layout: s.sections.map((x) => x.kind)
|
|
2910
|
+
layout: s.sections.map((x) => x.kind),
|
|
2911
|
+
...s.sections.length > 0 && s.sections.every((x) => x.role && x.geometry) ? { sections: s.sections.map((x) => ({ role: x.role, centred: x.geometry.centredShare, width: x.geometry.contentWidthRatio })) } : {}
|
|
2886
2912
|
};
|
|
2887
2913
|
}
|
|
2888
2914
|
|
|
@@ -2983,18 +3009,26 @@ function collectInPage() {
|
|
|
2983
3009
|
top: Math.round(box(h).top)
|
|
2984
3010
|
};
|
|
2985
3011
|
});
|
|
3012
|
+
const isButtonEl = (el) => el.tagName === "BUTTON" || el.tagName === "INPUT" && /^(submit|button)$/i.test(el.type);
|
|
3013
|
+
const isControl = (el) => {
|
|
3014
|
+
const isButton = isButtonEl(el);
|
|
3015
|
+
if (!isButton && el.tagName !== "A") return false;
|
|
3016
|
+
if (!shown(el)) return false;
|
|
3017
|
+
const cs = style(el);
|
|
3018
|
+
const b = box(el);
|
|
3019
|
+
const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
|
|
3020
|
+
if (!label || b.height < 24 || b.height > 90) return false;
|
|
3021
|
+
if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) return false;
|
|
3022
|
+
if (!isButton && px(cs.paddingLeft) < 8) return false;
|
|
3023
|
+
return true;
|
|
3024
|
+
};
|
|
2986
3025
|
const controls = [];
|
|
2987
3026
|
for (const el of all) {
|
|
2988
|
-
|
|
2989
|
-
const isButton =
|
|
2990
|
-
if (!isButton && tag !== "A") continue;
|
|
2991
|
-
if (!shown(el)) continue;
|
|
3027
|
+
if (!isControl(el)) continue;
|
|
3028
|
+
const isButton = isButtonEl(el);
|
|
2992
3029
|
const cs = style(el);
|
|
2993
3030
|
const b = box(el);
|
|
2994
3031
|
const label = (el.textContent ?? el.value ?? "").replace(/\s+/g, " ").trim();
|
|
2995
|
-
if (!label || b.height < 24 || b.height > 90) continue;
|
|
2996
|
-
if (!isButton && !(painted(cs.backgroundColor) || px(cs.borderTopWidth) >= 1)) continue;
|
|
2997
|
-
if (!isButton && px(cs.paddingLeft) < 8) continue;
|
|
2998
3032
|
const r = cs.borderTopLeftRadius;
|
|
2999
3033
|
const radius = r.endsWith("%") ? parseFloat(r) / 100 * b.height : px(r);
|
|
3000
3034
|
controls.push({
|
|
@@ -3074,7 +3108,80 @@ function collectInPage() {
|
|
|
3074
3108
|
}
|
|
3075
3109
|
return best;
|
|
3076
3110
|
};
|
|
3077
|
-
const
|
|
3111
|
+
const backgroundOf = (el) => {
|
|
3112
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
3113
|
+
const c = style(n).backgroundColor;
|
|
3114
|
+
if (painted(c)) return c;
|
|
3115
|
+
}
|
|
3116
|
+
return ground;
|
|
3117
|
+
};
|
|
3118
|
+
const geometryOf = (el, b) => {
|
|
3119
|
+
const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500).filter((n) => (ownText(n).length > 1 || /^(IMG|SVG|VIDEO|PICTURE|BUTTON|INPUT|TEXTAREA|SELECT)$/i.test(n.tagName)) && shown(n)).slice(0, 400);
|
|
3120
|
+
const centreX = b.left + b.width / 2;
|
|
3121
|
+
let left = 0;
|
|
3122
|
+
let right = 0;
|
|
3123
|
+
let covered = 0;
|
|
3124
|
+
let minX = Infinity;
|
|
3125
|
+
let maxX = -Infinity;
|
|
3126
|
+
let textBlocks = 0;
|
|
3127
|
+
let centred = 0;
|
|
3128
|
+
for (const n of leaves) {
|
|
3129
|
+
const r = box(n);
|
|
3130
|
+
const area = r.width * r.height;
|
|
3131
|
+
covered += area;
|
|
3132
|
+
minX = Math.min(minX, r.left);
|
|
3133
|
+
maxX = Math.max(maxX, r.left + r.width);
|
|
3134
|
+
const split = Math.max(0, Math.min(r.width, centreX - r.left));
|
|
3135
|
+
left += split * r.height;
|
|
3136
|
+
right += (r.width - split) * r.height;
|
|
3137
|
+
if (ownText(n).length > 1) {
|
|
3138
|
+
textBlocks += 1;
|
|
3139
|
+
const mid = r.left + r.width / 2;
|
|
3140
|
+
if (style(n).textAlign === "center" || Math.abs(mid - centreX) < vw * 0.03 && r.width < b.width * 0.8) centred += 1;
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
const round = (v) => Math.round(v * 1e3) / 1e3;
|
|
3144
|
+
return {
|
|
3145
|
+
centredShare: textBlocks ? round(centred / textBlocks) : 0,
|
|
3146
|
+
mirrorSymmetry: left + right > 0 ? round(1 - Math.abs(left - right) / (left + right)) : 1,
|
|
3147
|
+
whitespaceRatio: round(Math.max(0, 1 - Math.min(1, covered / Math.max(1, b.width * b.height)))),
|
|
3148
|
+
contentWidthRatio: leaves.length ? round(Math.min(1, (maxX - minX) / vw)) : 0,
|
|
3149
|
+
background: backgroundOf(el),
|
|
3150
|
+
controls: Array.from(el.querySelectorAll("a, button, input")).slice(0, 200).filter(isControl).length
|
|
3151
|
+
};
|
|
3152
|
+
};
|
|
3153
|
+
const CURRENCY = /[£$€]\s?\d/;
|
|
3154
|
+
const roleOf = (el, kind, geo, b, cards, iconCards, last) => {
|
|
3155
|
+
if (kind === "marquee" || kind === "hero") return kind;
|
|
3156
|
+
const heading = (el.querySelector("h1, h2, h3")?.textContent ?? "").toLowerCase();
|
|
3157
|
+
const leaves = Array.from(el.querySelectorAll("*")).slice(0, 1500);
|
|
3158
|
+
const own = leaves.map(ownText);
|
|
3159
|
+
const questions = own.filter((t) => /\?$/.test(t) && t.length > 8).length;
|
|
3160
|
+
if (el.querySelectorAll("details").length >= 3 || el.querySelectorAll("[aria-expanded]").length >= 3 || /\bfaq|frequently asked|questions\b/.test(heading) && questions >= 3) return "faq";
|
|
3161
|
+
const prices = own.filter((t) => CURRENCY.test(t) && t.length < 40).length;
|
|
3162
|
+
if (prices >= 2 && (cards >= 2 || /pric|plan|package/.test(heading))) return "pricing";
|
|
3163
|
+
const quotes = el.querySelectorAll("blockquote, q").length;
|
|
3164
|
+
if (quotes >= 2 || /testimonial|review|what (?:our )?(?:clients|customers|people) say/.test(heading)) return "testimonials";
|
|
3165
|
+
const steps = Math.max(0, ...Array.from(el.querySelectorAll("ol")).map((ol) => ol.children.length));
|
|
3166
|
+
const numbered = own.filter((t) => /^(?:step\s*)?0?[1-9][.:]?$/i.test(t)).length;
|
|
3167
|
+
if (steps >= 3 || numbered >= 3 || /how it works|our process|\bsteps?\b/.test(heading)) return "process";
|
|
3168
|
+
const form = el.querySelector("form");
|
|
3169
|
+
const fields = form ? form.querySelectorAll("input:not([type=hidden]):not([type=submit]), textarea, select").length : 0;
|
|
3170
|
+
if (fields >= 2 || /contact|get in touch/.test(heading)) return "contact";
|
|
3171
|
+
const portraits = Array.from(el.querySelectorAll("img")).filter((i) => {
|
|
3172
|
+
const r = box(i);
|
|
3173
|
+
return r.width >= 80 && r.width <= 420 && Math.abs(r.width - r.height) < r.width * 0.25;
|
|
3174
|
+
}).length;
|
|
3175
|
+
if (portraits >= 3 && /team|people|meet|who we are/.test(heading)) return "team";
|
|
3176
|
+
if (kind === "stats" || kind === "logos") return kind;
|
|
3177
|
+
const words = (el.textContent ?? "").replace(/\s+/g, " ").trim().length;
|
|
3178
|
+
const setApart = geo.background !== ground || geo.centredShare >= 0.6;
|
|
3179
|
+
if (setApart && heading && b.height <= vh * 0.9 && geo.controls >= 1 && geo.controls <= 2 && words < 400 && cards < 3) return last ? "footer-cta" : "cta-band";
|
|
3180
|
+
if (cards >= 3 && iconCards >= 2) return "features";
|
|
3181
|
+
return kind;
|
|
3182
|
+
};
|
|
3183
|
+
const kept = blocks.slice(0, 40);
|
|
3184
|
+
const sections = kept.map((el, index) => {
|
|
3078
3185
|
const b = box(el);
|
|
3079
3186
|
const heading = el.querySelector("h1, h2, h3");
|
|
3080
3187
|
const label = (heading?.textContent ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
@@ -3088,6 +3195,7 @@ function collectInPage() {
|
|
|
3088
3195
|
else if (images.length >= 4 && textChars < 300) kind = "logos";
|
|
3089
3196
|
else if (numbers >= 3 && textChars < 600) kind = "stats";
|
|
3090
3197
|
else if (cards >= 3) kind = "cards";
|
|
3198
|
+
const geometry = geometryOf(el, b);
|
|
3091
3199
|
return {
|
|
3092
3200
|
top: Math.round(b.top),
|
|
3093
3201
|
height: Math.round(b.height),
|
|
@@ -3095,7 +3203,9 @@ function collectInPage() {
|
|
|
3095
3203
|
label,
|
|
3096
3204
|
cards,
|
|
3097
3205
|
iconCards,
|
|
3098
|
-
hiddenAtLoad: b.top > vh && hiddenAtLoad(el)
|
|
3206
|
+
hiddenAtLoad: b.top > vh && hiddenAtLoad(el),
|
|
3207
|
+
role: roleOf(el, kind, geometry, b, cards, iconCards, index === kept.length - 1),
|
|
3208
|
+
geometry
|
|
3099
3209
|
};
|
|
3100
3210
|
});
|
|
3101
3211
|
if (header && sections.length === 0) {
|
|
@@ -3188,6 +3298,13 @@ function collectInPage() {
|
|
|
3188
3298
|
headings: headings2,
|
|
3189
3299
|
controls,
|
|
3190
3300
|
sections,
|
|
3301
|
+
rhythmVariance: (() => {
|
|
3302
|
+
const hs = sections.map((x) => x.height);
|
|
3303
|
+
if (hs.length < 2) return null;
|
|
3304
|
+
const mean = hs.reduce((t, v) => t + v, 0) / hs.length;
|
|
3305
|
+
const sd = Math.sqrt(hs.reduce((t, v) => t + (v - mean) ** 2, 0) / hs.length);
|
|
3306
|
+
return mean > 0 ? Math.round(sd / mean * 1e3) / 1e3 : null;
|
|
3307
|
+
})(),
|
|
3191
3308
|
effects,
|
|
3192
3309
|
motion: {
|
|
3193
3310
|
hiddenSections: sections.filter((s) => s.hiddenAtLoad).length,
|
|
@@ -3221,7 +3338,139 @@ function overlayInPage() {
|
|
|
3221
3338
|
return false;
|
|
3222
3339
|
}
|
|
3223
3340
|
|
|
3341
|
+
// src/direction/png.ts
|
|
3342
|
+
var import_node_zlib = require("zlib");
|
|
3343
|
+
function decodePng(buf) {
|
|
3344
|
+
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
3345
|
+
if (!sig.every((b, i) => buf[i] === b)) throw new Error("not a PNG");
|
|
3346
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
3347
|
+
let pos = 8;
|
|
3348
|
+
let width = 0;
|
|
3349
|
+
let height = 0;
|
|
3350
|
+
let depth = 0;
|
|
3351
|
+
let colour = 0;
|
|
3352
|
+
let interlace = 0;
|
|
3353
|
+
const idat = [];
|
|
3354
|
+
while (pos < buf.length) {
|
|
3355
|
+
const len = view.getUint32(pos);
|
|
3356
|
+
const type = String.fromCharCode(...buf.subarray(pos + 4, pos + 8));
|
|
3357
|
+
const data = buf.subarray(pos + 8, pos + 8 + len);
|
|
3358
|
+
if (type === "IHDR") {
|
|
3359
|
+
width = view.getUint32(pos + 8);
|
|
3360
|
+
height = view.getUint32(pos + 12);
|
|
3361
|
+
depth = data[8];
|
|
3362
|
+
colour = data[9];
|
|
3363
|
+
interlace = data[12];
|
|
3364
|
+
} else if (type === "IDAT") idat.push(data);
|
|
3365
|
+
else if (type === "IEND") break;
|
|
3366
|
+
pos += 12 + len;
|
|
3367
|
+
}
|
|
3368
|
+
const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colour];
|
|
3369
|
+
if (depth !== 8 || !channels || interlace !== 0) throw new Error(`unsupported PNG (depth ${depth}, colour type ${colour}${interlace ? ", interlaced" : ""}); export it as 8-bit RGB`);
|
|
3370
|
+
const raw = (0, import_node_zlib.inflateSync)(Buffer.concat(idat.map((d) => Buffer.from(d))));
|
|
3371
|
+
const stride = width * channels;
|
|
3372
|
+
const out = new Uint8Array(width * height * 4);
|
|
3373
|
+
const prev = new Uint8Array(stride);
|
|
3374
|
+
const line = new Uint8Array(stride);
|
|
3375
|
+
for (let y = 0; y < height; y += 1) {
|
|
3376
|
+
const filter = raw[y * (stride + 1)];
|
|
3377
|
+
const src = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
3378
|
+
for (let x = 0; x < stride; x += 1) {
|
|
3379
|
+
const a = x >= channels ? line[x - channels] : 0;
|
|
3380
|
+
const b = prev[x];
|
|
3381
|
+
const c = x >= channels ? prev[x - channels] : 0;
|
|
3382
|
+
let v = src[x];
|
|
3383
|
+
if (filter === 1) v += a;
|
|
3384
|
+
else if (filter === 2) v += b;
|
|
3385
|
+
else if (filter === 3) v += a + b >> 1;
|
|
3386
|
+
else if (filter === 4) {
|
|
3387
|
+
const p = a + b - c;
|
|
3388
|
+
const pa = Math.abs(p - a);
|
|
3389
|
+
const pb = Math.abs(p - b);
|
|
3390
|
+
const pc = Math.abs(p - c);
|
|
3391
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
3392
|
+
}
|
|
3393
|
+
line[x] = v & 255;
|
|
3394
|
+
}
|
|
3395
|
+
for (let x = 0; x < width; x += 1) {
|
|
3396
|
+
const i = x * channels;
|
|
3397
|
+
const o = (y * width + x) * 4;
|
|
3398
|
+
if (channels >= 3) {
|
|
3399
|
+
out[o] = line[i];
|
|
3400
|
+
out[o + 1] = line[i + 1];
|
|
3401
|
+
out[o + 2] = line[i + 2];
|
|
3402
|
+
out[o + 3] = channels === 4 ? line[i + 3] : 255;
|
|
3403
|
+
} else {
|
|
3404
|
+
out[o] = out[o + 1] = out[o + 2] = line[i];
|
|
3405
|
+
out[o + 3] = channels === 2 ? line[i + 1] : 255;
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
prev.set(line);
|
|
3409
|
+
}
|
|
3410
|
+
return { width, height, rgba: out };
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
// src/snapshot/visual.ts
|
|
3414
|
+
var EDGE_STEP = 32;
|
|
3415
|
+
function visualMeasures(img, maxSamples = 1e6) {
|
|
3416
|
+
const { width: w, height: h, rgba } = img;
|
|
3417
|
+
if (w < 2 || h < 2 || rgba.length < w * h * 4) throw new Error(`visualMeasures: ${w}x${h} image with ${rgba.length} bytes`);
|
|
3418
|
+
const step = Math.max(1, Math.ceil(Math.sqrt(w * h / maxSamples)));
|
|
3419
|
+
const lum = (x, y) => {
|
|
3420
|
+
const i = (y * w + x) * 4;
|
|
3421
|
+
return 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
|
|
3422
|
+
};
|
|
3423
|
+
let n = 0;
|
|
3424
|
+
let sumRg = 0;
|
|
3425
|
+
let sumYb = 0;
|
|
3426
|
+
let sumRg2 = 0;
|
|
3427
|
+
let sumYb2 = 0;
|
|
3428
|
+
let edges = 0;
|
|
3429
|
+
let edgeN = 0;
|
|
3430
|
+
let asym = 0;
|
|
3431
|
+
let asymN = 0;
|
|
3432
|
+
for (let y = 0; y < h; y += step) {
|
|
3433
|
+
for (let x = 0; x < w; x += step) {
|
|
3434
|
+
const i = (y * w + x) * 4;
|
|
3435
|
+
const r = rgba[i];
|
|
3436
|
+
const g = rgba[i + 1];
|
|
3437
|
+
const b = rgba[i + 2];
|
|
3438
|
+
const rg = r - g;
|
|
3439
|
+
const yb = 0.5 * (r + g) - b;
|
|
3440
|
+
sumRg += rg;
|
|
3441
|
+
sumYb += yb;
|
|
3442
|
+
sumRg2 += rg * rg;
|
|
3443
|
+
sumYb2 += yb * yb;
|
|
3444
|
+
n += 1;
|
|
3445
|
+
const l = lum(x, y);
|
|
3446
|
+
if (x + step < w && y + step < h) {
|
|
3447
|
+
if (Math.abs(lum(x + step, y) - l) + Math.abs(lum(x, y + step) - l) > EDGE_STEP) edges += 1;
|
|
3448
|
+
edgeN += 1;
|
|
3449
|
+
}
|
|
3450
|
+
if (x < w / 2) {
|
|
3451
|
+
asym += Math.abs(l - lum(w - 1 - x, y));
|
|
3452
|
+
asymN += 1;
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
const meanRg = sumRg / n;
|
|
3457
|
+
const meanYb = sumYb / n;
|
|
3458
|
+
const sdRg = Math.sqrt(Math.max(0, sumRg2 / n - meanRg * meanRg));
|
|
3459
|
+
const sdYb = Math.sqrt(Math.max(0, sumYb2 / n - meanYb * meanYb));
|
|
3460
|
+
const colourfulness = Math.sqrt(sdRg ** 2 + sdYb ** 2) + 0.3 * Math.sqrt(meanRg ** 2 + meanYb ** 2);
|
|
3461
|
+
const round = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
|
|
3462
|
+
return {
|
|
3463
|
+
colourfulness: round(colourfulness, 1),
|
|
3464
|
+
edgeDensity: round(edgeN ? edges / edgeN : 0),
|
|
3465
|
+
symmetry: round(asymN ? 1 - asym / asymN / 255 : 1),
|
|
3466
|
+
width: w,
|
|
3467
|
+
height: h,
|
|
3468
|
+
sampled: n
|
|
3469
|
+
};
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3224
3472
|
// src/audit/index.ts
|
|
3473
|
+
var VISUAL_MAX_HEIGHT = 6e3;
|
|
3225
3474
|
async function loadPlaywright() {
|
|
3226
3475
|
for (const name of ["playwright", "playwright-core"]) {
|
|
3227
3476
|
try {
|
|
@@ -3239,12 +3488,23 @@ async function snapshotPage(page, url, options = {}) {
|
|
|
3239
3488
|
await page.waitForTimeout(options.introWindowMs ?? 5e3);
|
|
3240
3489
|
const late = await page.evaluate(overlayInPage).catch(() => false);
|
|
3241
3490
|
const data = await page.evaluate(collectInPage);
|
|
3491
|
+
let visual;
|
|
3492
|
+
if (page.screenshot && options.visual !== false) {
|
|
3493
|
+
try {
|
|
3494
|
+
const height = Math.min(data.pageHeight, VISUAL_MAX_HEIGHT);
|
|
3495
|
+
const png = await page.screenshot({ fullPage: true, type: "png", clip: { x: 0, y: 0, width: data.viewport.width, height } });
|
|
3496
|
+
visual = visualMeasures(decodePng(png));
|
|
3497
|
+
} catch {
|
|
3498
|
+
visual = void 0;
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3242
3501
|
return {
|
|
3243
3502
|
version: SNAPSHOT_VERSION,
|
|
3244
3503
|
url,
|
|
3245
3504
|
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3246
3505
|
...data,
|
|
3247
|
-
motion: { ...data.motion, introOverlay: early && !late }
|
|
3506
|
+
motion: { ...data.motion, introOverlay: early && !late },
|
|
3507
|
+
...visual ? { visual } : {}
|
|
3248
3508
|
};
|
|
3249
3509
|
}
|
|
3250
3510
|
async function launch(pw, url, options) {
|
|
@@ -3270,7 +3530,9 @@ async function snapshotUrls(urls, options = {}) {
|
|
|
3270
3530
|
for (const url of urls) {
|
|
3271
3531
|
const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
|
|
3272
3532
|
try {
|
|
3273
|
-
await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
|
|
3533
|
+
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: options.timeoutMs ?? 45e3 });
|
|
3534
|
+
const status = typeof response?.status === "function" ? response.status() : 200;
|
|
3535
|
+
if (status >= 400) throw new Error(`HTTP ${status}`);
|
|
3274
3536
|
out.push({ url, snapshot: await snapshotPage(page, url, options) });
|
|
3275
3537
|
} catch (error) {
|
|
3276
3538
|
out.push({ url, error: error.message });
|
|
@@ -3289,6 +3551,7 @@ async function audit(url, options = {}) {
|
|
|
3289
3551
|
}
|
|
3290
3552
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3291
3553
|
0 && (module.exports = {
|
|
3554
|
+
VISUAL_MAX_HEIGHT,
|
|
3292
3555
|
audit,
|
|
3293
3556
|
collectInPage,
|
|
3294
3557
|
loadPlaywright,
|