create-website-build-kit 0.1.6 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-website-build-kit",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Scaffold a production marketing site \u2014 Astro on Cloudflare Workers, with the gates, the migration playbook and the accessibility work already wired.",
5
5
  "keywords": [
6
6
  "astro",
@@ -11,6 +11,7 @@ npm run media # after adding or replacing any ima
11
11
  npm run build:staging && npx wrangler dev # localhost:8788, real KV + secrets
12
12
  npm run a11y # accessibility check, one URL per family
13
13
  npm run tells # what is undecided, and the design tells
14
+ npm run check:copy # author notes that reached the rendered page
14
15
  npm run recon -- https://old-site.com # inventory the old site BEFORE designing routes
15
16
  npm run dns -- old-site.com # capture the zone. MX loss kills client email
16
17
  npm run seo -- https://old-site.com # optional: SEO baseline to diff after cutover
@@ -93,6 +94,14 @@ The rules whose failure looks like success — double-counted pageviews, a conve
93
94
  two pipes, a trigger on `sent=1` that catches almost nothing — are in `docs/analytics.md`.
94
95
  Read it before adding any tag.
95
96
 
97
+ **Notes to yourself never ship.** `check:copy` reads the text a browser would show — not the
98
+ source, not comments, not `<script>` — and looks for the markers people actually leave: `TODO`,
99
+ `FIXME`, `⚠ CONFIRM:`, `Lorem ipsum`, an unrendered `{{ placeholder }}`. It warns on staging and
100
+ **refuses on production**, because a note is normal while building and unacceptable at go-live.
101
+ One shipped as body copy on a service page — *"⚠ CONFIRM: the old site advertised classes every
102
+ Saturday at 9am…"* — past clean types, clean axe and clean tells. **The question is usually real:
103
+ move it to `BUILD-STATE.md`, do not just delete it.**
104
+
96
105
  **Evidence, not assertions.** `npm run a11y:evidence` writes a dated pack to
97
106
  `docs/a11y-evidence/` — commit it. It fills in the machine half and leaves the keyboard,
98
107
  screen-reader and forms passes **blank**, because no tool does those; a pack with them still
@@ -287,6 +287,7 @@ degrades into. Run the script; keep the commands below for when you need to see
287
287
  | --- | --- |
288
288
  | One pageview per visit | Only visible in analytics Realtime |
289
289
  | A valid submission stores **and** emails | Sending one creates a real lead and a real notification |
290
+ | **Data the client may not hold is dropped** | Only if `compliance.md` §10 named something — PHI, GDPR special categories, card numbers. Craft a submission containing exactly it, send it, then read the stored record back. The form not *asking* is not the control; the message box accepts anything. `verify` cannot do this: it would need the export token and the record schema |
290
291
  | The analytics container is the client's own | Fetch `gtm.js` and read it — see `analytics.md` |
291
292
  | A redirect lands on the **right** page | The script proves it resolves, not that it is correct |
292
293
  | It looks right on a phone | — |
@@ -490,16 +491,27 @@ In this order. Steps 1–3 happen days ahead, not on launch day.
490
491
  3. **Move Search Console verification to DNS TXT** if it currently relies on an HTML file, and
491
492
  confirm it still shows verified. File-based verification breaks the moment the file stops
492
493
  resolving, and losing verification loses the property's history.
493
- 4. **Full verification matrix against staging.** Every row.
494
- 5. **Deploy production** `npm run deploy:production`. Read the bindings table in the output;
494
+ 4. **Name what enforces every retention period the privacy notice states.** KV is done for you —
495
+ `leadRetentionDays` becomes an `expirationTtl` and the store enforces it. **Anything else is
496
+ not.** R2 keeps uploaded files forever unless the bucket has a **lifecycle rule**, and that
497
+ rule lives in the Cloudflare dashboard, so nothing in the repo and no gate here can see it.
498
+ D1 needs a scheduled delete you wrote.
499
+
500
+ ⚠ A notice claiming a period nothing enforces is a false statement, and it fails silently:
501
+ clean build, clean deploy, correct-looking policy, data still there a year later. Caught on a
502
+ real build where résumés went to R2 while the retention value drove only the KV record and the
503
+ page copy. If you cannot name the mechanism, build it or change the notice —
504
+ `stacks.md` §6.
505
+ 5. **Full verification matrix against staging.** Every row.
506
+ 6. **Deploy production** — `npm run deploy:production`. Read the bindings table in the output;
495
507
  it is the only visible signal that the right environment was built.
496
- 6. **Cut DNS over.** Watch, do not assume:
508
+ 7. **Cut DNS over.** Watch, do not assume:
497
509
  ```bash
498
510
  dig +short $PROD; dig +short www.$PROD
499
511
  curl -sI "https://$PROD/" | head -3
500
512
  ```
501
- 7. **Remove the staging route** from the worker, or staging becomes an indexable duplicate.
502
- 8. **Re-run the matrix against production**, including `robots.txt` (must now allow) and
513
+ 8. **Remove the staging route** from the worker, or staging becomes an indexable duplicate.
514
+ 9. **Re-run the matrix against production**, including `robots.txt` (must now allow) and
503
515
  `noindex` (must now be absent).
504
516
 
505
517
  ```bash
@@ -511,7 +523,7 @@ In this order. Steps 1–3 happen days ahead, not on launch day.
511
523
  `check:secrets` already ran as part of `deploy:production`. Run it again here because
512
524
  go-live is when it is most likely to fail: a secret set on the staging worker is not
513
525
  automatically on this one, and the failure is silent — leads store, nothing emails.
514
- 9. **Submit the sitemap** in [Search Console](https://search.google.com/search-console) and
526
+ 10. **Submit the sitemap** in [Search Console](https://search.google.com/search-console) and
515
527
  [Bing Webmaster Tools](https://www.bing.com/webmasters). If you kept the old filename, the
516
528
  existing entry keeps working and there is nothing to resubmit.
517
529
 
@@ -526,7 +538,7 @@ In this order. Steps 1–3 happen days ahead, not on launch day.
526
538
  submission and rejects the whole batch with a 403 if it is not live yet. And it is Bing,
527
539
  Yandex, Seznam and Naver: **Google does not participate**, so this is never the reason a
528
540
  page is or is not in Google. The script prints both of those on every run.
529
- 10. **Diff the zone against the capture.** The one step that catches a launch taking the
541
+ 11. **Diff the zone against the capture.** The one step that catches a launch taking the
530
542
  client's email with it:
531
543
 
532
544
  ```bash
@@ -536,10 +548,10 @@ In this order. Steps 1–3 happen days ahead, not on launch day.
536
548
  MX, SPF, DMARC, verification TXT and nameservers, compared against what was published
537
549
  before you touched anything. A dead site gets a phone call; dead email is silent.
538
550
 
539
- 11. **Send one real enquiry through the live form** and confirm the client received it in the
551
+ 12. **Send one real enquiry through the live form** and confirm the client received it in the
540
552
  inbox they actually read.
541
- 12. **Restore the DNS TTL** to something sane (3600s).
542
- 13. **Point the uptime monitor at a real page and the form endpoint** — not just the homepage.
553
+ 13. **Restore the DNS TTL** to something sane (3600s).
554
+ 14. **Point the uptime monitor at a real page and the form endpoint** — not just the homepage.
543
555
  The endpoint is what breaks.
544
556
 
545
557
  ---
@@ -29,12 +29,13 @@
29
29
  "redirects": "node scripts/redirects.mjs",
30
30
  "handover": "node scripts/md-to-pdf.mjs docs/handover.md docs/handover.pdf",
31
31
  "build": "astro build",
32
- "build:staging": "PUBLIC_SITE_ENV=staging PUBLIC_SITE_URL=https://new.example.com astro build && PUBLIC_SITE_ENV=staging node scripts/staging-headers.mjs && PUBLIC_SITE_ENV=staging node scripts/check-env.mjs",
33
- "build:production": "node scripts/tells.mjs --undecided-only && PUBLIC_SITE_ENV=production astro check && PUBLIC_SITE_ENV=production PUBLIC_SITE_URL=https://example.com astro build && PUBLIC_SITE_ENV=production node scripts/check-env.mjs && node scripts/check-sitemap.mjs",
32
+ "build:staging": "node scripts/build.mjs staging",
33
+ "build:production": "node scripts/build.mjs production",
34
34
  "preview": "wrangler dev",
35
35
  "deploy:staging": "npm run build:staging && wrangler deploy && node scripts/check-secrets.mjs",
36
36
  "deploy:production": "npm run build:production && wrangler deploy && node scripts/check-secrets.mjs",
37
- "check:secrets": "node scripts/check-secrets.mjs"
37
+ "check:secrets": "node scripts/check-secrets.mjs",
38
+ "check:copy": "node scripts/check-copy.mjs"
38
39
  },
39
40
  "dependencies": {
40
41
  "@astrojs/cloudflare": "^14.1.7",
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Run a build for one environment.
3
+ *
4
+ * node scripts/build.mjs staging
5
+ * node scripts/build.mjs production
6
+ *
7
+ * `npm run build:staging` and `npm run build:production` are one-line aliases
8
+ * for these.
9
+ *
10
+ * ── WHY A SCRIPT AND NOT INLINE ENV IN package.json ────────────────────────
11
+ * ⚠ `PUBLIC_SITE_ENV=staging astro build` IS POSIX SHELL SYNTAX, AND npm ON
12
+ * WINDOWS RUNS SCRIPTS THROUGH cmd.exe. There it is not an assignment, it is
13
+ * a command name, and the build dies immediately with
14
+ *
15
+ * 'PUBLIC_SITE_ENV' is not recognized as an internal or external command
16
+ *
17
+ * So the two most important commands in the kit did not work at all on a
18
+ * platform the kit says it supports. Nothing caught it because every CI job
19
+ * ran on ubuntu.
20
+ *
21
+ * ── AND WHY IT IS SAFER EVEN WHERE THE SHELL SYNTAX WORKS ──────────────────
22
+ * The production line repeated `PUBLIC_SITE_ENV=production` four times, once
23
+ * per command. Miss one and that step runs as `development` while the others
24
+ * do not: `astro check` types a different environment than the one that gets
25
+ * built, or `check-env` validates an environment nobody deployed.
26
+ *
27
+ * A mixed-environment build is precisely what `check-env.mjs` exists to catch,
28
+ * and it is the sort of thing that survives review because every line looks
29
+ * right on its own. Setting it once removes the class.
30
+ */
31
+
32
+ import { spawnSync } from 'node:child_process';
33
+ import { existsSync, readFileSync } from 'node:fs';
34
+
35
+ const RESET = '\x1b[0m';
36
+ const RED = '\x1b[31m';
37
+ const DIM = '\x1b[2m';
38
+
39
+ const env = process.argv[2];
40
+
41
+ if (env !== 'staging' && env !== 'production') {
42
+ console.error(
43
+ `\n${RED}✗ usage: node scripts/build.mjs <staging|production>${RESET}\n\n` +
44
+ ' The environment is not optional. A build that does not declare one\n' +
45
+ ' emits localhost canonical URLs — cleanly, and wrong.\n',
46
+ );
47
+ process.exit(1);
48
+ }
49
+
50
+ /*
51
+ * The site URL per environment. Read from package.json rather than restated
52
+ * here, so there is one place a project sets its hostnames.
53
+ *
54
+ * ⚠ Kept OUT of src/data/site.ts on purpose: astro.config.mjs needs the value
55
+ * before any TypeScript is loaded, and site.ts imports `import.meta.env`.
56
+ */
57
+ const SITE_URLS = {
58
+ staging: 'https://new.example.com',
59
+ production: 'https://example.com',
60
+ };
61
+
62
+ /**
63
+ * Run one step with the environment already set, and stop the build if it
64
+ * fails. `shell: true` on Windows because `npx`-style shims are .cmd files
65
+ * that execFile cannot resolve — the same reason the a11y scripts need it.
66
+ */
67
+ function step(command, args) {
68
+ const run = spawnSync(command, args, {
69
+ stdio: 'inherit',
70
+ env: { ...process.env, PUBLIC_SITE_ENV: env, PUBLIC_SITE_URL: SITE_URLS[env] },
71
+ shell: process.platform === 'win32',
72
+ });
73
+ if (run.status !== 0) {
74
+ console.error(`\n${RED}✗ build failed at:${RESET} ${command} ${args.join(' ')}\n`);
75
+ process.exit(run.status ?? 1);
76
+ }
77
+ }
78
+
79
+ /* astro is a local binary; go through the package's own bin rather than a
80
+ global `astro`, which may not exist and would be the wrong version if it did. */
81
+ const astro = ['node_modules/astro/astro.js'];
82
+ const hasLocalAstro = existsSync('node_modules/astro/astro.js');
83
+
84
+ const run = (args) =>
85
+ hasLocalAstro
86
+ ? step(process.execPath, [...astro, ...args])
87
+ : step('npx', ['--no-install', 'astro', ...args]);
88
+
89
+ console.log(`${DIM}building ${env} → ${SITE_URLS[env]}${RESET}\n`);
90
+
91
+ if (env === 'production') {
92
+ /* Refuse a production build of a template that has not been designed yet.
93
+ Before anything else, because it is the cheapest check and the most
94
+ embarrassing thing to deploy. */
95
+ step(process.execPath, ['scripts/tells.mjs', '--undecided-only']);
96
+ run(['check']);
97
+ }
98
+
99
+ run(['build']);
100
+
101
+ if (env === 'staging') {
102
+ step(process.execPath, ['scripts/staging-headers.mjs']);
103
+ }
104
+
105
+ /* Warn on staging, refuse on production. A note in the copy is normal WHILE
106
+ building and unacceptable at go-live — the same split as
107
+ `tells --undecided-only`. */
108
+ step(process.execPath, ['scripts/check-copy.mjs', ...(env === 'production' ? ['--strict'] : [])]);
109
+
110
+ step(process.execPath, ['scripts/check-env.mjs']);
111
+
112
+ if (env === 'production') {
113
+ step(process.execPath, ['scripts/check-sitemap.mjs']);
114
+ }
115
+
116
+ /* A sanity line, so the log says which environment actually ran rather than
117
+ which one was asked for. They are the same now; they were not always. */
118
+ const pkg = existsSync('package.json') ? JSON.parse(readFileSync('package.json', 'utf8')) : {};
119
+ console.log(`\n${DIM}${pkg.name ?? 'site'} built as ${env}${RESET}`);
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Refuse to ship a build whose visible copy still talks to the author.
3
+ *
4
+ * node scripts/check-copy.mjs # warn
5
+ * node scripts/check-copy.mjs --strict # exit 1 on any marker
6
+ *
7
+ * `build:production` runs it strict; `build:staging` runs it as a warning,
8
+ * because a note in the copy is normal WHILE building and unacceptable at
9
+ * go-live. Same split as `tells --undecided-only`.
10
+ *
11
+ * ── THE FAILURE THIS EXISTS FOR ────────────────────────────────────────────
12
+ * A real build shipped this, on a service page, as body text a parent would
13
+ * read:
14
+ *
15
+ * "⚠ CONFIRM: the old site advertised classes every Saturday at 9am. Does
16
+ * this continue under the concierge model? Emitting a class time nobody is
17
+ * running is a locked door."
18
+ *
19
+ * It was written inline in the content file while drafting, and every gate
20
+ * passed over it: `astro check` clean, axe clean, `tells` clean, links fine.
21
+ * Nothing in a build can tell a sentence meant for the client from a sentence
22
+ * meant for the reader — except a list of the markers people actually use.
23
+ *
24
+ * ⚠ THE QUESTION IS USUALLY REAL. The fix is not to delete the note, it is to
25
+ * move it to `BUILD-STATE.md` where the other open questions live. A build
26
+ * record is read before go-live; a paragraph on a service page is read by a
27
+ * customer.
28
+ *
29
+ * ── WHY THIS LOOKS AT RENDERED TEXT, NOT THE FILE ──────────────────────────
30
+ * Searching source would flag every code comment that mentions TODO, and
31
+ * searching raw HTML would flag markers inside `<script>` and inside HTML
32
+ * comments — which are invisible to a reader and often deliberate. The check
33
+ * runs on the text a browser would show, plus JSON-LD, because a placeholder in
34
+ * structured data is quoted straight back by Google.
35
+ *
36
+ * `check-sitemap.mjs` carries the same lesson in the opposite direction: match
37
+ * the thing, not the word, or you train people to ignore the check.
38
+ */
39
+
40
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
41
+ import { join, relative, sep } from 'node:path';
42
+
43
+ const RESET = '\x1b[0m';
44
+ const RED = '\x1b[31m';
45
+ const GREEN = '\x1b[32m';
46
+ const YELLOW = '\x1b[33m';
47
+ const DIM = '\x1b[2m';
48
+
49
+ const strict = process.argv.includes('--strict');
50
+
51
+ const root = ['dist/client', 'dist'].find((d) => existsSync(d));
52
+ if (!root) {
53
+ console.error('check-copy: no dist/ — run a build first.');
54
+ process.exit(1);
55
+ }
56
+
57
+ /*
58
+ * ⚠ EVERY PATTERN HERE IS DELIBERATELY NARROW.
59
+ *
60
+ * `TODO` in caps is never ordinary prose. Lowercase "todo" is a Spanish word
61
+ * and an English noun. `CONFIRM` needs its colon or its warning sign, because
62
+ * "confirm your email address" is a real sentence a form says. Bare `TK` is
63
+ * excluded entirely — two capitals appear inside acronyms and product names,
64
+ * and a check that fires on those is a check people switch off.
65
+ */
66
+ const MARKERS = [
67
+ { name: 'TODO', re: /\bTODO\b/ },
68
+ { name: 'FIXME', re: /\bFIXME\b/ },
69
+ { name: 'XXX', re: /\bXXX\b/ },
70
+ { name: 'TKTK', re: /\bTKTK\b/ },
71
+ { name: 'CONFIRM:', re: /(?:⚠\s*)?\bCONFIRM\s*:/ },
72
+ { name: 'placeholder text', re: /\bLorem ipsum\b/i },
73
+ { name: 'unresolved template', re: /\{\{\s*[\w.]+\s*\}\}/ },
74
+ ];
75
+
76
+ const walk = (dir) =>
77
+ readdirSync(dir).flatMap((e) => {
78
+ const full = join(dir, e);
79
+ return statSync(full).isDirectory() ? walk(full) : [full];
80
+ });
81
+
82
+ /** What a reader actually sees, plus structured data. Never script or comments. */
83
+ function visibleText(html) {
84
+ const jsonLd = [...html.matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)]
85
+ .map((m) => m[1])
86
+ .join('\n');
87
+
88
+ const body = html
89
+ .replace(/<!--[\s\S]*?-->/g, ' ')
90
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
91
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
92
+ .replace(/<[^>]+>/g, ' ');
93
+
94
+ return `${body}\n${jsonLd}`;
95
+ }
96
+
97
+ const hits = [];
98
+
99
+ for (const file of walk(root).filter((f) => f.endsWith('.html'))) {
100
+ const text = visibleText(readFileSync(file, 'utf8'));
101
+ for (const { name, re } of MARKERS) {
102
+ if (!re.test(text)) continue;
103
+ /* Show the sentence, so the reader can judge it without opening the file. */
104
+ const at = text.search(re);
105
+ const context = text
106
+ .slice(Math.max(0, at - 60), at + 120)
107
+ .replace(/\s+/g, ' ')
108
+ .trim();
109
+ hits.push({
110
+ route: '/' + relative(root, file).split(sep).join('/').replace(/index\.html$/, ''),
111
+ name,
112
+ context,
113
+ });
114
+ }
115
+ }
116
+
117
+ if (!hits.length) {
118
+ console.log(`${GREEN}✓${RESET} no author notes in the rendered copy`);
119
+ process.exit(0);
120
+ }
121
+
122
+ const label = strict ? `${RED}✗${RESET}` : `${YELLOW}!${RESET}`;
123
+ console[strict ? 'error' : 'log'](
124
+ `\n${label} ${hits.length} author marker(s) in text a reader would see\n`,
125
+ );
126
+ for (const h of hits) {
127
+ console[strict ? 'error' : 'log'](` ${h.route} ${DIM}[${h.name}]${RESET}\n …${h.context}…`);
128
+ }
129
+
130
+ console[strict ? 'error' : 'log'](
131
+ `\n ${DIM}The question is usually real. Move it to BUILD-STATE.md, where the other\n` +
132
+ ` open questions live and someone reads it before go-live — do not just delete it.${RESET}\n`,
133
+ );
134
+
135
+ process.exit(strict ? 1 : 0);
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
31
- import { join, relative } from 'node:path';
31
+ import { join, relative, sep } from 'node:path';
32
32
 
33
33
  const RESET = '[0m';
34
34
  const RED = '[31m';
@@ -78,7 +78,13 @@ const ROBOTS_META = /<meta[^>]+name=["']robots["'][^>]+content=["'][^"']*\bnoind
78
78
  const noindexed = walk(root)
79
79
  .filter((f) => f.endsWith('.html'))
80
80
  .filter((f) => ROBOTS_META.test(readFileSync(f, 'utf8')))
81
- .map((f) => '/' + relative(root, f).replace(/index\.html$/, '').replace(/\.html$/, '/'))
81
+ /* SEPARATORS NORMALISED — `relative()` RETURNS BACKSLASHES ON WINDOWS.
82
+ A URL path is always `/`. Without this the map produced `/about\\` for
83
+ `about\\index.html`, which matched nothing: check-sitemap's contradiction
84
+ check silently passed a site that listed a noindexed URL in its sitemap, and
85
+ Search Console reports that as an error against the whole submission. */
86
+ .map((f) => '/' + relative(root, f).split(sep).join('/')
87
+ .replace(/index\.html$/, '').replace(/\.html$/, '/'))
82
88
  .map((p) => (p.endsWith('/') ? p : `${p}/`));
83
89
 
84
90
  const contradictions = noindexed.filter((p) => listed.has(p));
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { existsSync, readdirSync, statSync } from 'node:fs';
14
- import { join, relative } from 'node:path';
14
+ import { join, relative, sep } from 'node:path';
15
15
 
16
16
  /** Routes this build emitted, as absolute paths. 404 is excluded — it is not a route. */
17
17
  export function routesFromDist() {
@@ -26,7 +26,13 @@ export function routesFromDist() {
26
26
 
27
27
  return walk(root)
28
28
  .filter((f) => f.endsWith('.html') && !f.endsWith('404.html'))
29
- .map((f) => '/' + relative(root, f).replace(/index\.html$/, '').replace(/\.html$/, '/'))
29
+ /* SEPARATORS NORMALISED — `relative()` RETURNS BACKSLASHES ON WINDOWS.
30
+ A URL path is always `/`. Without this the map produced `/about\\` for
31
+ `about\\index.html`, which matched nothing: check-sitemap's contradiction
32
+ check silently passed a site that listed a noindexed URL in its sitemap, and
33
+ Search Console reports that as an error against the whole submission. */
34
+ .map((f) => '/' + relative(root, f).split(sep).join('/')
35
+ .replace(/index\.html$/, '').replace(/\.html$/, '/'))
30
36
  .map((p) => (p.endsWith('/') ? p : `${p}/`))
31
37
  .sort();
32
38
  }
@@ -313,6 +313,56 @@ tell(
313
313
  );
314
314
  }
315
315
 
316
+ /*
317
+ * Two rows adopted after running pbakaus/impeccable's detector over a real
318
+ * build. Both are signals `tells` did not have; both needed an exclusion its
319
+ * version does not have, and the exclusions are the reason they are usable.
320
+ */
321
+
322
+ /*
323
+ * "bounce and overshoot easing" — a cubic-bezier whose control points leave
324
+ * [0,1] on the y axis overshoots the target and springs back.
325
+ *
326
+ * ⚠ NOT ALWAYS WRONG, which is why it is a tell and not an error. Overshoot is
327
+ * right when the gesture itself carried momentum — a flick, a drag release.
328
+ * It is wrong on a menu that faded in, where nothing threw it.
329
+ */
330
+ {
331
+ const overshoot = [...sourceCss.matchAll(/cubic-bezier\(([^)]+)\)/g)]
332
+ .map((m) => m[1].split(',').map((n) => Number(n.trim())))
333
+ .filter((p) => p.length === 4 && p.every((n) => Number.isFinite(n)))
334
+ .filter(([, y1, , y2]) => y1 > 1 || y2 > 1 || y1 < 0 || y2 < 0);
335
+ tell(
336
+ 'bounce or overshoot easing',
337
+ overshoot.length > 0,
338
+ `${overshoot.length} easing curve(s) overshoot. Right after a flick or a drag release, where momentum came from the gesture; dated on anything that simply appeared.`,
339
+ );
340
+ }
341
+
342
+ /*
343
+ * "a thick accent bar down one side of a card" — border-inline-start (or
344
+ * border-left) at 3px or more.
345
+ *
346
+ * ⚠ BLOCKQUOTES ARE EXCLUDED, AND THAT EXCLUSION IS THE WHOLE ROW. A rule
347
+ * flagged `.prose blockquote { border-inline-start: 3px solid }` on a real
348
+ * build — a left rule on a quotation is a typographic convention older than
349
+ * the web, and reporting it would teach people to skim past the row. The
350
+ * pattern actually meant is a notice or card wearing a coloured tab.
351
+ */
352
+ {
353
+ const bars = [...sourceCss.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
354
+ .filter(([, selector]) => !/blockquote|\bquote\b|\bcite\b/i.test(selector))
355
+ .filter(([, , body]) => {
356
+ const m = /border-(?:inline-start|left):\s*([\d.]+)px\s+solid/.exec(body);
357
+ return m && Number(m[1]) >= 3;
358
+ });
359
+ tell(
360
+ 'a thick accent bar down one side',
361
+ bars.length > 1,
362
+ `${bars.length} rule(s) put a 3px+ coloured border on one edge. On a card or a notice it is one of the most recognisable generated-UI tells. Blockquotes are excluded — a rule beside a quotation is a convention, not a tell.`,
363
+ );
364
+ }
365
+
316
366
  // "the 404, the empty state or the form's invalid state was never designed"
317
367
  tell(
318
368
  'no invalid / busy form state',
@@ -189,7 +189,12 @@
189
189
  --dur-slow: 420ms;
190
190
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
191
191
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
192
- --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
192
+ /* ⚠ NO SPRING TOKEN, DELIBERATELY. An overshoot curve is a look, and the
193
+ template does not ship looks — it shipped one anyway, unused, until a
194
+ detector found it in a client project that had inherited the dead token
195
+ without ever referencing it. Add one where a gesture carries real
196
+ momentum, and only there: a flick or a drag release. Nothing that merely
197
+ appeared should spring. */
193
198
 
194
199
  /* ── Chrome ────────────────────────────────────────────────────────────── */
195
200
  --header-h: 4.5rem;