From 67847d94fa30fad70ef8429ea0df124a41cea7c9 Mon Sep 17 00:00:00 2001 From: Pouya Lajevardi Date: Wed, 2 Sep 2026 16:14:35 -0400 Subject: [PATCH] fix: regenerate favicon.ico with a transparent ground; tick Pouya's read-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit public/favicon.ico shipped with no transparency: all three frames declared a 32-bit alpha channel and then carried alpha=255 on every one of their 256/1024/ 2304 pixels, ground opaque cream rgba(250,247,242,255). Pouya's read-through finding, confirmed by parsing the ICO container directly. The render source carries true alpha (2,272,386 transparent px, 20,795 partial), so this is an export, not a mask derived from the cream ground — R13's harder branch did not fire and R13 is unchanged on its own terms. New scripts/icons.mjs + npm run icons re-derives the icon from the committed master: asserts the source is still the documented crop (R14), verifies a candidate file and renames on success so a rejected build cannot replace a good favicon, and runs a boundary-colour halo test. Composition is unchanged — ink bbox and pixel count identical at all three sizes. apple-touch-icon.png is byte-identical and stays opaque cream deliberately; the reason lives in docs/reference/brand-assets.md §The icon set, with the bar and a pointer in BaseLayout.astro, docs/06 and R13. docs/06: the read-through is ticked, and the cutover callout drops to ONE blocker — Q60's waiting period. Two adversarial review rounds, 15 findings, all resolved, none declined; stopped at two per D19. claims-auditor correctly deferred to cutover per D20. Gates on the committed bytes, exit status read: check 0, build 0 (23 pages), check:claims 0, check:intake 0, og:proof 0, lint 0, lighthouse 0 (worst of 23 99/100/100/100). Nothing deployed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5 --- AGENTS.md | 372 ++++++++++++++++++++++++++++++++- CLAUDE.md | 28 ++- docs/06-deployment.md | 75 +++++-- docs/reference/brand-assets.md | 151 ++++++++++++- package.json | 3 +- public/favicon.ico | Bin 4857 -> 5365 bytes scripts/icons.mjs | 278 ++++++++++++++++++++++++ src/layouts/BaseLayout.astro | 6 +- 8 files changed, 891 insertions(+), 22 deletions(-) create mode 100644 scripts/icons.mjs diff --git a/AGENTS.md b/AGENTS.md index 727d35d..2862c3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -932,7 +932,7 @@ never being raised again. | R10 | ✅ **FIRED AND SATISFIED 2026-09-02 — THE CUTOVER EVENT AND THE CONFIRMATION, IN THAT ORDER. THE ROW STAYS LIVE.** Asked of Pouya on 2026-09-02 as a one-line question — *are ADRIC, ADRIO, the three OBA sections and the CTF all still current?* — and **answered the same day: all current.** It was asked rather than looked up, which is the entire content of this reminder: **a stamp is not a renewal receipt, and re-reading an old one is not re-confirming.** It was open for part of the day, alongside R18 which fired and was satisfied the same day. **Re-stamped on all three surfaces, because `memberOf` is emitted and the constant feeds both:** §4's memberships row, `CREDENTIALS.memberships` in `src/data/site.ts` and the R10 note in `src/data/schema.ts`. ⚠️ **AND THERE ARE TWO ARRAYS, NOT ONE — re-stamping is not the same act as checking they still agree.** `CREDENTIALS.memberships` feeds `/about/`'s visible list and `/bio/`; **`MEMBERSHIP_ORGS` feeds `/process/` §Confidentiality and the `memberOf` triples**, and `site.ts` records that the two differ on three of four lines. `_MembershipParity` compares their **`['length']` only**, so a substitution passes `npm run check` in silence. An earlier form of this row said one constant fed both surfaces, which would have left `/process/` and the JSON-LD publishing a lapsed membership after a correct-looking edit — the OCNI failure with a green build (`adversarial-reviewer`, round 1). `docs/06`'s item is ticked and **re-armed for the next republish** — the trigger is an event and events recur, which is why this row does not close on being satisfied. Original text follows. ⚠️ **A THIRD SURFACE, 2026-08-30: `/process/` §Confidentiality renders `MEMBERSHIP_ORGS[0]` ("I am a member of the ADR Institute of Canada").** It is rendered from the constant rather than typed, so the sweep this reminder prescribes reaches it — that was `adversarial-reviewer`'s finding and the fix, in that order. **DISCHARGED AS WRITTEN 2026-08-28 — AND RE-ARMED WITH AN EVENT TRIGGER INSTEAD OF A DATE. STILL LIVE.** Pouya re-confirmed all four memberships as current (Q44), which discharges the prohibition this row carried, and `/about/` now publishes the Memberships group. **The row does not close, because he declined renewal-date tracking**, and that was his instruction for what to do about it: *"Without renewal months it cannot fire on a date, so make it fire on an event: re-confirm memberships before any cutover or major republish, and re-stamp §4 when confirmed."* **THE TRIGGER: re-confirm before any cutover, and before any major republish. Then re-stamp §4 the same day.** **His reason, kept verbatim because it is the general principle and not a membership detail:** *"§4 already carries OCNI as lapsed and unpublishable, and that was found roughly a year late. A stamp with no trigger behind it goes stale silently, which is exactly how OCNI got onto a list of things to feature."* **Two things the discharge did NOT license.** (1) **No currency warranty on the page** — list the memberships, promise nothing about their future state; the struck sentence stays struck and nothing replaces it. (2) ~~`memberOf` stays out of the JSON-LD~~ — **SUPERSEDED. Q53, ruled 2026-08-28: EMIT IT.** `/about/`'s Person node now carries the four memberships as `Organization` nodes. Pouya took `adversarial-reviewer`'s argument: they are already crawlable in `/about/`'s HTML, so withholding the triple reduced no exposure and only made the graph less complete than the page. **The consequence for THIS reminder is that it now covers two surfaces** — re-confirming before a cutover means `src/data/schema.ts` as well as the visible list, and they must not be allowed to diverge. **Renewal periods, stated once and not widened again:** the OBA sections and the CTF renew yearly; §4 records **nothing** about ADRIC's or ADRIO's period, and the widened form ("all four renew yearly") reached four files before it was swept. *Previous text described the prohibition and the withheld group; it held for one session and did its job.* | 2026-08-26 | A credential that lapses quietly is the failure mode §4 exists to prevent, and OCNI already did exactly this. The group is on a public page now, which raises the cost of a lapse rather than lowering it — *(This rationale ended by pointing at **Q48** as a possible widening of the row. Q48 closed 2026-08-28 as not site-relevant — ADRIO retention governs whether Pouya keeps a designation, not what the site may say about holding one — so the clause is struck. §12 is read aloud every session; a live reminder pointing at a struck row produces a false surface every time.)*, not just a list | | R11 | **Re-check dependency currency at every phase boundary in the build order** (`docs/01-architecture.md` §Build order, 11 steps). Run `npm view version` across **every** pin in `package.json` and compare; do not wait for something to break. Verified does not mean latest — record the reason for any deliberate hold in §7. ✅ **THE STEP-7 RE-ADD TRIGGER IS DISCHARGED, 2026-08-31 — and NOT as written.** It said *"at step 7, put `@lhci/cli` back"*. `@lhci/cli` is still 0.15.1, still `latest`, and still carries 10 findings (7 high) `[verified 2026-08-31]`, so the literal instruction would have re-added a tool with seven high-severity advisories. What shipped is **`lighthouse@13.4.1` + `chrome-launcher@1.2.1`, 0 vulnerabilities**, as `npm run lighthouse`. **The reason is that §7's own advisory attribution was wrong** — it blamed `lighthouse → puppeteer-core → extract-zip`; the carriers were `@lhci/cli`'s own `tmp` and `@puppeteer/browsers`' `extract-zip`, and neither exists in Lighthouse's tree. **The last clause of this trigger is the one that earned its place:** *"if the advisories are still unfixed, that is a decision to take deliberately, not a reason to leave the gap unstated."* They are still unfixed; the decision was taken; §7 records what it costs (no `lhci` assertion config, no server, no run history) and that the gate is local rather than CI, because standalone Lighthouse needs an installed browser and the runner has none. **All six UNAVAILABLE notices are deleted** — `docs/04` (budget table, Performance callout, post-launch checklist), `CLAUDE.md` (performance budget, definition of done), `/build` Phase 5, `docs/06` (PR checks, cutover checklist), `.claude/agents/adversarial-reviewer.md` §4. The **general** half of R11 — re-check every pin at every phase boundary — is untouched and still fires. ✅ **SWEPT AGAIN 2026-09-01, all 19 pins against `npm view`, and TWO MAJORS ARE DEFERRED BY RULING rather than left unstated:** `@astrojs/mdx` **^7.0.8 → 8.0.0** and `typescript` **^6.0.3 → 7.0.2**. Pouya's reasoning — *"npm audit is clean and majors mid-walkthrough add churn without user value"* — with `npm audit` at **0 vulnerabilities** `[verified 2026-09-01]`, which makes it a churn decision and not a security one, **and one that flips the moment that stops being true.** Both now sit on a new **Cutover prep** group at the head of `docs/06`'s cutover checklist, dated, because deferring a thing and forgetting it look identical three weeks later. Four more are a minor or patch behind and already satisfied by their carets, so they need no edit — `astro` 7.2.9 → 7.2.10, `@astrojs/sitemap` 3.7.3 → 3.7.4, `globals` 17.11.0 → 17.12.0, `typescript-eslint` 8.68.0 → 8.69.0; the other 13 are current. **This row is the deferral's reminder, not its replacement** — R11 fires at the next phase boundary regardless | 2026-08-26 | `astro: "^5.0.0"` was recalled rather than checked and was two majors stale the day it was written, which meant a framework carrying high-severity XSS advisories. Between phases is cheap; after a phase of pages is written is not. The build order has ten more boundaries | | R12 | **`compressHTML: true` is a deliberate deviation from the Astro 7 default (`'jsx'`).** Measured 2026-08-26: in an `.astro` template an inline pair split across two lines renders as `ab` under the default — the space is silently deleted. MDX prose is unaffected | 2026-08-26 | It is a deviation, and undocumented deviations become folklore. Revisit **with a measurement**, not a preference — and re-measure after any Astro major, since the behaviour could change again | -| R13 | **The infinity mark ships as a RASTER, and that is temporary. RAISED 2026-08-27; Pouya ruled the committed SVG does NOT close it** — *"Keep it committed, keep the AVIF render path. Your own measurement is the reason: 257 KB wrapping seven embedded base64 PNGs. It renders faithfully because it IS the raster."* So the exception stands and the reminder stays live. `InfinityMark.astro` renders an optimised AVIF/WebP from `src/assets/brand/sml-infinity-mark.png` — a deliberate, documented exception to `docs/02`'s "inline SVG, never a PNG", because the mark is gradient-mesh artwork and no true vector master exists yet (Q38). **Removal trigger: the commissioned vector master lands.** Then replace the `` with inline SVG, regenerate `favicon.ico` and `apple-touch-icon.png` from it, and delete the exception from `docs/02`, from the component, and from Q38 | 2026-08-26 | Pouya flagged this himself when he made the ruling: *an interim raster is exactly the kind of temporary measure that becomes permanent by never being raised.* It costs ~8 KB and works, which is precisely why nobody will notice it again. There is no build error to prompt anyone — only this row | +| R13 | **The infinity mark ships as a RASTER, and that is temporary. RAISED 2026-08-27; Pouya ruled the committed SVG does NOT close it** — *"Keep it committed, keep the AVIF render path. Your own measurement is the reason: 257 KB wrapping seven embedded base64 PNGs. It renders faithfully because it IS the raster."* So the exception stands and the reminder stays live. `InfinityMark.astro` renders an optimised AVIF/WebP from `src/assets/brand/sml-infinity-mark.png` — a deliberate, documented exception to `docs/02`'s "inline SVG, never a PNG", because the mark is gradient-mesh artwork and no true vector master exists yet (Q38). **Removal trigger: the commissioned vector master lands.** Then replace the `` with inline SVG, regenerate `favicon.ico` and `apple-touch-icon.png` from it, and delete the exception from `docs/02`, from the component, and from Q38. ⚠️ **The favicon half of that is now a command rather than a manual step — `npm run icons`, added 2026-09-02** — but it reads the RASTER, so pointing it at a vector master is part of the work this row is waiting for. **It regenerates the favicon only. `apple-touch-icon.png` is still hand-made, and it must stay OPAQUE CREAM when it is remade** — the reason is an external platform behaviour that this repository has not tested, and `docs/reference/brand-assets.md` §The icon set carries it with its provenance. Do not restate it here as a fact | 2026-08-26 | Pouya flagged this himself when he made the ruling: *an interim raster is exactly the kind of temporary measure that becomes permanent by never being raised.* It costs ~8 KB and works, which is precisely why nobody will notice it again. There is no build error to prompt anyone — only this row | | R14 | **Anything a spec makes a claim about must be reachable from the repository.** If the artefact lives only in Drive, in a console, or on someone's disk, no reviewer can check the claim and the claim is unverifiable by construction — see the rule now in `CLAUDE.md` | 2026-08-26 | **This has now cost twice.** Q24 was `AWS-Hosting-Guide.md`, the only record of how the infrastructure was hand-built, living outside the repo. Q32 was the infinity mark: Claude Code traced it from the old site's *loading placeholder*, wrote a scrupulous provenance comment recording exactly that doubt — and **two adversarial review passes still could not catch it**, because the artwork they would have had to compare against was not in the repo. The doubt was correctly stated and structurally uncheckable. That is the failure mode: not a missing warning, a missing artefact | | ~~R15~~ | ✅ **DISCHARGED 2026-08-31 — build step 7b. Seventeen pages and every future article now carry their own generated card; `/` and `/about/` keep the portrait, which Q40 called "not an interim" and which is unchanged.** `src/pages/og/[...slug].jpg.ts` renders them at build from `satori@0.33.4` + `sharp`; `src/data/og-cards.ts` is the registry; `SEO.astro` resolves which of the two kinds a page gets from its own pathname, so the decision lives in one data file rather than in nineteen call sites. `satori` over `astro-og-canvas@0.13.0` — both 0 vulnerabilities `[verified 2026-08-31]` — because `sharp` was already a dependency, so it is one library rather than a CanvasKit wasm blob, and it renders the site's real fonts and tokens instead of approximating them. **The interim note is deleted from `docs/04` and the cutover line from `docs/06`**, which is what this row's trigger asked for. ⚠️ **AND IT SURFACED A HOLE THAT IS WORTH MORE THAN THE ROW: TEXT BAKED INTO A JPEG CANNOT BE GREPPED BY `npm run check:claims`, WHICH UNDER D20 IS THE ONLY PER-STEP CLAIMS CONTROL THERE IS.** A card is the one surface on this site where new prose would face no mechanical check at all. So card copy is constrained structurally rather than editorially: **every headline is its page's own `

`, character for character**, the kicker renders `CREDENTIALS.designations`, and **`npm run og:proof` enforces both against the built HTML** — it fails if the registry and the page disagree in either direction, and it also confirms every `og:image` resolves to a file that exists. That check found a straight-versus-typographic apostrophe on its first run. **A page with no registry entry is a build error, not a fallback to the portrait** — R15's failure mode was never the wrong image, it was the wrong image shipping *invisibly* and reading as intentional, and a silent fallback recreates it exactly. **What is NOT discharged and is deliberately left open:** the check compares strings, so it cannot see a card that renders badly — a headline that overflows, a mark that clips. `npm run og:proof -- --sheet` writes a contact sheet of every card for that, and **a human still has to look**; `docs/06`'s cutover item says so. Two cards were read at step 7b (`/practice/energy/`, the longest headline, and `/practice/insurance/`) and rendered correctly | 2026-08-27 | Nobody on this project will ever see the defect. A link preview is rendered by LinkedIn, Slack and Teams for a reader who is not us, and `docs/04` requires a **unique** title and description per page — nineteen unique pages that all preview identically is the failure, and it is invisible from inside the repo. It cost nothing and worked, which is exactly the R13 shape | | ~~R16~~ | ✅ **DISCHARGED 2026-08-31 — build step 9, which is the trigger this row set. The one-page PDF exists, is committed at `public/pouya-lajevardi-bio.pdf`, and `/about/` links it.** Q45's two open sub-decisions are both taken, and the second one is what makes the first safe. **(a) Generated at build, or authored once? NEITHER — and the third option answers this row's actual objection.** The bio is a PAGE, `src/pages/bio.astro`, so every line of it is reviewed by the same apparatus as every other page: `astro check`, `npm run check:claims` on the built HTML, the per-step code review, and the cutover claims pass. `npm run bio:pdf` then RENDERS the PDF from the built page through Chrome, with **no new dependency** — `chrome-launcher` is already a devDependency because Lighthouse needs it. That is aimed squarely at what this row said the problem was: *"It is the one artefact class this project's review apparatus cannot reach… a PDF circulated with an appointment proposal is read once, by the reader who matters most, and never seen by a reviewer again."* A rendering of a reviewed page is back inside the apparatus. **It is deliberately NOT part of `astro build`** — CI has no Chrome, and a build step that cannot run in CI is Q22's shape again — so the PDF is committed, which R14 wants anyway. **(b) Does it carry anything the site does not? NO, and that is the answer that avoids the §4 question this row flagged.** Every line renders from `CREDENTIALS`, `ROLE`, `BOUTIQUE`, `PRACTICE_AREAS`, `FEES` and `CONTACT`. No matter list — which this row correctly said *"would collide with §4 Forbidden directly"* — no referees, and no figure that is not on `/fees/`. The fee summary IS there, because this row's own reasoning is that an appointment proposal needs the rate card as much as the bio. ⚠️ **`npm run bio:pdf` ASSERTS ONE PAGE and writes nothing if the count is wrong** — counted from the PDF's own page objects, cross-checked against the page tree's `/Count`. A one-page bio that silently becomes two is invisible from the source, because it depends on the print stylesheet, the paper size and how much §4 has grown. **And reading the rendered PDF caught a real breach the source review had not:** the opening sentence read *"I act as a neutral in commercial disputes — as a mediator, as an arbitrator in commercial matters…"*, whose leading clause scoped **mediation**, which Q56 leaves unscoped deliberately. `/bio/` is `noindex` and out of the sitemap: it is a condensed duplicate of `/about/` and `/fees/`. **What is NOT discharged:** nothing in the build regenerates the PDF. It must be re-rendered and re-committed whenever `/bio/`, §4, the rate card or the print styles change, and `docs/06`'s cutover checklist carries that | 2026-08-28 | It is the one artefact class this project's review apparatus cannot reach. A web page is re-reviewed by every audit and re-checked by every build; a PDF circulated with an appointment proposal is read once, by the reader who matters most, and never seen by a reviewer again | @@ -948,6 +948,376 @@ never being raised again. # Change Log +## 2026-09-02 (aq) — The favicon shipped with no transparency and is regenerated from the committed master; Pouya's full-site read-through completes with that as its only finding, and the cutover blockers drop to one + +**Pouya's read-through of every page against §4 is COMPLETE, 2026-09-02.** It is +the human half of D20 and it is not delegable. Across 23 pages it returned +**one finding, and the finding was not copy**: `public/favicon.ico` had no +transparency. His read carried the approvals with it in terms — the +`/legal/privacy/` §Who can see it wording, the **SML Company Ltd** consent line, +and `/med-arb/` **as shipped** — which discharges the wording sign-off that +entry (ap) had routed into this pass. + +### 1. The finding, confirmed before it was acted on + +Pouya's note: *"`public/favicon.ico` has zero transparent pixels; every +background pixel is opaque cream (250,247,242,255)."* Confirmed by parsing the +ICO container directly rather than by converting it — a conversion step can +itself add or drop an alpha channel, and then the measurement is about the +converter. All three frames are PNG-encoded and **declare a 32-bit alpha +channel**, and then carry `alpha = 255` on every one of their **256 / 1,024 / +2,304** pixels, `range = [255,255]`. The ground is `rgba(250,247,242,255)`, +which is `--cream` `#faf7f2` exactly. `magick identify` reports these frames as +`8-bit sRGB` and says nothing about alpha either way, which is why the container +was read directly. + +**A declared alpha channel that is opaque everywhere is the worst shape this +defect could take**: every tool reports the file as RGBA and nothing flags it. + +### 2. It is an EXPORT, not a derivation — the R13 branch did not fire + +Pouya's instruction allowed for the harder case: *"If the master itself has no +alpha channel, derive the mask from the cream ground and flag R13."* **Measured, +and it does not apply.** `src/assets/brand/sml-infinity-mark.png` carries a true +alpha channel — 2668 × 1704, **2,272,386 fully transparent pixels (50.0%)**, +2,253,091 fully opaque, and **20,795 partial** — the anti-aliased edge of the +ribbon, intact. So no mask had to be reconstructed from the cream ground, and +nothing here is lossier than the artwork. **R13 is untouched by this** and stays +open on its own terms: the mark still ships as a raster. + +The regeneration also **re-derived the documented crop as a side effect**: +extracting `{ left: 159, top: 646, width: 2668, height: 1704 }` from +`sml-infinity-mark-master.png` returns a buffer **byte-identical** to the render +source. `scripts/icons.mjs` asserts that on every run, so the icon stays +traceable to committed artwork rather than to a file on a disk — R14. + +### 3. What changed, and what deliberately did not + +`public/favicon.ico`, 16/32/48, **4,857 → 5,365 B**. The ground is now +transparent; **nothing else moved.** + +**The composition was measured off the icons as they already shipped rather than +re-chosen** — the mark spans **7/8 of the canvas width**, centred on both axes, +which is 14/16, 28/32, 42/48 and 158/180 on the touch icon. Regenerating had to +reproduce that, not restyle the mark, and it does: composited back onto cream, +the mark's bounding box and ink-pixel count are **identical at all three sizes** +— 14 × 9 at (1,4), 28 × 18 at (2,7), 42 × 27 at (3,11), ink 92 / 328 / 689. + +⚠️ **`public/apple-touch-icon.png` STAYS OPAQUE CREAM, DELIBERATELY, AND IS +BYTE-IDENTICAL ACROSS THIS CHANGE** (`sha256 6377b096…`, asserted before and +after). **iOS composites a transparent touch icon onto BLACK**, so the fix for +the tab icon is the defect for the home-screen icon. The two requirements are +opposite and the asymmetry is now recorded in three places, because the risk is +that someone later "fixes" the touch icon to match: `docs/reference/brand-assets.md` +§The icon set, the comment beside the `` tags in +`src/layouts/BaseLayout.astro`, and R13. + +### 4. Verified two ways, on two instruments that cannot fail the same way + +Pouya asked for both, and asked for the dark ground specifically. + +**(a) Programmatically, on the shipped `dist/` bytes.** The container parsed +frame by frame: three frames, dir sizes matching the decoded PNGs, **all four +corners `alpha = 0` at every size**, `147 / 636 / 1,499` fully transparent +pixels, `15 / 88 / 202` fully opaque, `94 / 300 / 603` partial. The mark is +still there and the ground is gone. + +**(b) Composition parity.** Composite the new icon back onto cream and it +reproduces the old matted icon to within **1 of 255 on every channel of every +pixel** — max delta **1**, mean **0.01–0.02**, **0** pixels over a delta of 8, +at all three sizes. That proves the regeneration did not restyle, rescale or +reposition the mark. + +⚠️ **AND IT IS NOT A HALO TEST, THOUGH THIS ENTRY'S FIRST DRAFT FILED IT AS ONE +— `adversarial-reviewer`, round 1.** The claim written beside it, *"anti-aliasing +that had been matted against cream would differ far more than that"*, is the +inversion of the truth: **matting against cream is exactly what produces delta +0.** A cream-matted edge that has merely had its background knocked out +composites straight back to the icon it came from, so both the correct and the +defective case pass. **The test returns the same near-zero answer either way and +therefore carries no information about the thing it was filed under.** + +**(b2) No halo — the measurement that DOES discriminate.** Read the RGB the +partial-alpha pixels actually carry: cream means a halo, the ribbon's own colour +means a clean export. **0 of 94 / 300 / 603** partial-alpha pixels are within 12 +of `rgb(250,247,242)`; the nearest is **13** away, mean distance **174.0 / 178.3 +/ 177.0**, mean colour ≈ `rgb(118,86,74)`. Maroon-brown, not cream and not black. +Run independently after the finding rather than taken from it, and it reproduces +the reviewer's figures exactly. + +**(c) Visually, on seven grounds — and then again in a browser.** The frames +composited on Chrome dark `#202124`, Firefox dark `#2b2a33`, Safari dark +`#3b3b3b`, black, Chrome light `#f1f3f4`, white and cream, at 1:1 and at 5× +nearest-neighbour. The old icon shows a hard cream tile on every dark ground and +a visible off-white rectangle even on white; the new one shows no tile anywhere. +**Then the same page was rendered by Chrome itself** — `` +on the same grounds, screenshotted headless at DPR 2 — because sharp decoding +its own output is one instrument twice. Chrome's ICO decoder honours the alpha +on every ground including pure black. + +**(d) Legible on dark AND on light — but NOT at the same cost, and the first +draft of this entry recorded it as free.** Two defects, both found by +`adversarial-reviewer` round 1 and both re-measured here before being accepted. + +*The figure was wrong.* `rgb(64,22,24)` was quoted as the darkest ink at 32 px +and it is **not a painted colour**: exactly one pixel carries it and its alpha is +**251**, not 255. The probe filtered at `alpha >= 250` and then computed a +contrast ratio as though the pixel were opaque. **A ratio taken from a raw +channel value is a claim about a colour that is never painted.** Recomputed on +fully-opaque pixels only, composited against the ground: darkest opaque ink at +32 px is `rgb(60,29,29)` → **13.62:1** on `#f1f3f4`, not 14.02. + +*The characterisation was worse than the figure.* "Reads as a single-tone loop +rather than the full two-tone ribbon" understates it. The maroon lobe composites +to **1.04–1.15:1** on `#202124`. Counting pixels reaching 3:1 against each +ground, at 16 / 32 / 48 px: + +| | vs `#202124` | vs `#f1f3f4` | +|---|---|---| +| 16 px | **23** / 256 | 40 / 256 | +| 32 px | **87** / 1024 | 168 / 1024 | +| 48 px | **189** / 2304 | 378 / 2304 | + +**Roughly half the mark's visible pixels are absent on a dark tab strip, and the +previous cream-tiled icon was more legible there.** What the change buys is the +removal of that cream rectangle from every dark strip and from white. A +`prefers-color-scheme` icon pair does not solve it — that keys off the *page's* +scheme, not the browser chrome's. **The trade is recorded as a trade**; it is +Pouya's to revisit and §4 of this entry's record is where he would find it. + +**(e) Deterministic.** Three consecutive runs of `npm run icons` produce the +identical file, `sha256 3c611a43…`. A generator that drifts would make every +later comparison meaningless. + +### 5. An instrument artefact, caught and recorded rather than believed + +The first geometry re-check reported the mark's bounding box on the new 48 px +frame as **48 × 48 filling the whole canvas** — which reads as *the regeneration +blew the mark up to the full square*, a serious defect. **It is an artefact of +the probe.** That probe identifies ink as "differs from cream by more than 6", +which is valid on a matted icon and **invalid on a transparent one**: at +`alpha = 0` the pixels carry RGB `(0,0,0)`, which differs from cream, so the +entire canvas reads as ink. Re-measured by compositing over cream first — so both +icons are measured by the same instrument on the same kind of input — the boxes +are identical, which is §3 above. + +⚠️ **`CLAUDE.md` lists six instances of *a measurement is a claim about your +instrument until you check the instrument*. THIS SESSION ADDED TWO MORE**, and +they are different failure modes from each other and from the six: + +- **A probe that is valid on one class of input and silently invalid on + another.** "Differs from cream" identifies ink correctly on a matted icon and + nonsensically on a transparent one, because `alpha = 0` pixels still carry RGB. + Nothing about the probe changed — **the input class did**, and the probe had no + way to say so. +- **A contrast ratio computed without asking what alpha the colour is painted + at.** The 32 px "darkest ink" was a single pixel at `alpha = 251`, filtered in + by a `>= 250` threshold and then treated as opaque. **The number was of a + colour that is never painted.** + +Both are now in `CLAUDE.md` beside the other six. + +### 6. Records + +- `docs/06-deployment.md` — **the cutover callout drops from TWO blockers to + ONE.** ⚠️ **It read ONE earlier the same day and that was a defect**, so the + callout now states the difference rather than the number: it said ONE then + because an approval had gone **missing** from the list; it says ONE now + because the pass that approval was routed into has been **done**. A tally + cannot tell those apart. **Q60's waiting period is the last one**, and it is a + waiting period rather than a task — earliest useful reading 48 hours, no + failure called before 7 days, so it starts first. +- `docs/06-deployment.md` — **"Pouya has read every page against §4"** ticked, + with the finding and the three approvals it carried recorded on the item. It + notes in terms that this does **not** discharge `claims-auditor`'s cutover + pass: D20 traded the per-step machine audit for the human pass **plus** one + machine pass over the finished site, and one of those two has now happened. +- `docs/06-deployment.md` — **"Favicon set complete"** ticked, carrying what was + wrong, what fixed it, both verifications, and the touch-icon bar. +- `docs/reference/brand-assets.md` — a new **§The icon set**: the two files, + their grounds and why they differ, the composition rule, the regeneration + command, and the measurements above. Its opening line said *"Every measurement + below is `[verified 2026-08-26]`"*, which the new section would have made + false, so sections now carry their own stamps. +- `AGENTS.md` **R13** — amended in place. Its removal trigger says to regenerate + both icons from the vector master when it lands; that half is now a command + (`npm run icons`) **but it reads the raster**, so pointing it at a vector master + is part of the work R13 is still waiting for. The row also now carries the bar + that the touch icon must stay opaque cream when it is remade. +- `scripts/icons.mjs` + `npm run icons` — new. **LOCAL ONLY and deliberately not + wired into `npm run build` or either deploy path**, like `bio:pdf` and + `lighthouse`; verified by grep that no workflow or deploy script calls it. It + writes the favicon **only** and never opens the touch icon. It refuses to run + if the render source has no alpha channel, asserts the source is still the + documented crop of the master, and re-reads the container **from disk** to + verify rather than trusting the buffers it just wrote. + +### 7. No new compatibility surface + +Worth stating because it bounds the risk of touching a file every page requests: +**the container format did not change.** The previous `favicon.ico` already +carried three **PNG-encoded** frames at 32 bpp, and so does this one — same +frame count, same sizes, same ascending order, same 6-byte header and three +16-byte directory entries, first payload at offset 54. Only the pixel data +differs. Anything that could parse the old file parses this one. Verified in +Chrome; the remaining browsers are covered by the existing unticked checklist +item, **"Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox"**, +which is where the touch icon gets looked at on a real handset. + +### 8. Review — `adversarial-reviewer` only, per D20 + +**`claims-auditor` did NOT run.** D20: it runs once, at cutover, over the whole +finished site. This change set adds no copy to any page. + +**Round 1: seven findings, none blocking, all seven acted on, none declined.** +Two of them — the halo proof and the legibility figures — are recorded above +because they were defects **in this entry's own evidence**, which is the class +worth keeping. The other five: + +- **The external constants were dressed as repository facts.** `#202124`, + `#f1f3f4` and iOS's compositing behaviour are none of them derivable from + anything committed, and they sat under a heading promising every figure was + read off this repository. **This is R14 from the inside**: a reader cannot + re-derive `11.28:1` without a ground colour they have no way to check. The + hexes now carry `[observed 2026-09-02]`; the iOS behaviour is attributed to + **Pouya's ruling of 2026-09-02** and marked as not re-tested here, with the + `docs/06` handset item named as the place that would test it. +- **The new stamp mis-dated the render ladders** — and ⚠️ **the fix for this was itself defective; see §11.** The line replacing the file's + old blanket date asserted the ladders were `[verified 2026-08-26]`; they carry + their own `[measured 2026-08-27]` eleven lines lower. **The fix made a vaguer + wrong claim more specifically wrong** — so the header now sends the reader to + each section's own stamp and states that a blanket date has already been wrong + here once. **Round 2 found that this replacement had removed the only stamp + five sections had**; it is now a floor plus overrides. +- **The generator wrote the shipped file BEFORE verifying it.** The real defect + of the seven. `writeFileSync(OUT, …)` then `verify(OUT)` means a rejected + build has already replaced a good favicon, exits 1, and — because the script + is deliberately outside the build and both deploy paths — **nothing downstream + re-checks**. A developer who fixes the artwork without `git checkout` ships the + frames the script rejected, which is the exact failure this change was made to + correct. Now it writes `favicon.ico.candidate`, verifies **that**, and renames + on success; `die()` removes the candidate on every failure path. **Probed both + ways:** forcing the matted-ground check to fail exits **1**, prints the right + error, leaves no candidate, and leaves `public/favicon.ico` at its original + `sha256 3c611a43…`. +- **The iOS rationale was stated in full in four places.** `CLAUDE.md`'s + duplicated-fact rule with no mechanism attached — the shape that put a stale + SES DKIM table into `docs/05`. `docs/reference/brand-assets.md` §The icon set + is now the single owner; the other sites carry the **bar** and a pointer, not + the reasoning. `BaseLayout.astro`'s addition went from 6 lines to 3 for two + `` elements. +- **`npm run icons` was missing from `CLAUDE.md`'s command inventory**, whose + next sentence counts the commands. Added, along with the distinction that + `bio:pdf` and `icons` are **regenerators rather than gates** — they rewrite + committed artefacts, and they stay out of the build because a build should not + silently rewrite an artefact a human approved. + +Also removed while in the file: two unreachable `size === 256` branches, a +return value no caller used, and a duplicate `metadata()` read. + +### 9. Verification — every command run, every exit status read + +On the committed bytes, `npm run` unless noted: + +| | | +|---|---| +| `check` | **0** — 0 errors, 0 warnings, 0 hints | +| `build` | **0** — 23 pages | +| `check:claims` | **0** — 12 patterns, every pattern still firing on its fixture | +| `check:intake` | **0** — 12 form fields against 12 handler fields | +| `og:proof` | **0** — 23 pages, 22 og:image targets, 21 headlines matched their `

` | +| `lint` | **0** — eslint + prettier | +| `lighthouse` | **0** — worst of 23 **99 / 100 / 100 / 100**, CLS 0.000, run before and after the review fixes | +| `icons` | **0**, and byte-identical on three consecutive runs | + +Icon-specific, and the numbers that matter: + +- `public/favicon.ico` **4,857 → 5,365 B**; `dist/favicon.ico` identical to it. +- `public/apple-touch-icon.png` **`sha256 6377b096…7de0` before and after**, in + `public/` and in `dist/`. Asserted, not assumed. +- Corners `alpha = 0` at 16, 32 and 48, read back from the shipped `dist/` bytes. +- Ink bbox and ink-pixel count identical to the previous icon at all three sizes. +- The failure path probed: a forced rejection exits **1** and leaves + `public/favicon.ico` at its original hash with no candidate file behind. + +**Not claimed:** the touch icon has not been looked at on a handset, and the +favicon has been rendered in **Chrome only**. Both belong to the existing +unticked `docs/06` item *"Tested on iOS Safari, Android Chrome, desktop +Safari/Chrome/Firefox"*, which is the right place for them and is not ticked by +this entry. + +### 10. What remains + +**Q60 is the only thing blocking the cutover list**, and it is a waiting period +rather than a task: TTL is `ENABLED`, no record has yet been watched to expire, +`/legal/privacy/` asserts the mechanism and not merely the period, and `docs/09` +Part 10's earliest useful reading is **48 hours** with no failure called before +**7 days**. **Start it first.** + +`claims-auditor`'s single cutover pass over the finished site is still to run — +D20 traded the per-step machine audit for the human pass **plus** that one, and +only the human pass has happened. + +### 11. Round 2 — seven should-fix and one consider, all acted on, none declined + +**Stopped at two rounds, per D19.** The cap exists because past two the +manufacture rate exceeds the value of the marginal finding; this round is the +evidence for the other half of that rule — **five of its eight items were defects +in round 1's own repairs.** Recording that, not summarising it away. + +**The one that mattered, and it was mine twice over.** Round 1 established that +the composite-onto-cream test cannot detect a halo, and the fix put the +discriminating test in the prose. Round 2's point was that **`verify()` still +could not run it** — the generator asserted frame count, dimensions, clear +corners, "some transparent" and "some opaque", every one of which a cream-matted +knockout passes. So the guard went in. **And the first guard was blind too.** + +It counted **partial-alpha** pixels near cream — and a purpose-built haloed +fixture has **zero partial-alpha pixels**, because a knockout sets alpha per +pixel and leaves none. The rule inspected an empty set, returned 0, and read as +a pass. **It passed the real icon and it passed the defect**, which is the +uniform-pass shape `CLAUDE.md` warns is the unsurvivable half. Caught by +building the fixture before trusting the guard. + +The working rule is the **boundary**: every painted pixel touching a fully +transparent one, counted for nearness to cream. Correct frames **1.6 / 1.4 / +1.9 %**; the fixture **19.6 %**; gate at **10 %**, ~5× clear of both. Probed +both ways — the fixture exits **1** and leaves `public/favicon.ico` at its hash. + +The other seven: + +- **The round-1 stamp fix deleted the floor five sections relied on.** Replacing + a blanket `[verified 2026-08-26]` with *"each section carries its own stamp"* + was true of two sections and false of five, which then had **no date at all** + on 3000×3000, 2668×1704, 257,278 B and the rest. **A vaguer wrong claim was + made more specifically wrong.** Now a floor plus overrides — the only shape + that covers every section without mis-dating one. +- **The `prefers-color-scheme` alternative was dismissed on a mechanism that is + wrong.** It does not key off the page's colour scheme; it reports the user's + preference, and this site declares `color-scheme` nowhere (`git grep -n + color-scheme -- src`, exit 1) — so it resolves to the same preference that + makes the tab strip dark. **A wrong reason there is load-bearing**: it is what + would stop the next implementer re-examining an accepted cost. The real + objections are recorded as **untested**. +- **"Roughly half the mark's visible pixels are absent on dark" overstated its + own measurement.** They are painted and fall below 3:1, and "visible pixels" + (`alpha > 0`) is 109 / 388 / 805, which is not what was counted either. Now: + about half as many pixels reach 3:1. +- **The iOS constant was hedged in one file and asserted flat in two** — R13 and + `docs/06`, the two places a caveat matters most, since §12 is read every + session and `docs/06` is what an operator works at cutover. Both now point. +- **`docs/06` restated six of the doc's figures and had already diverged**, within + one day: it quoted 11.28:1 / 13.62:1 unlabelled, which are the **32 px** row, + after a 16 px row (8.82:1 / 12.58:1) was added; and it said "≥3:1 pixels" where + the doc said "visible pixels", a different denominator. Cut to a pointer. +- **The newly ticked human-pass item still carried an unstruck imperative** — + *"START WITH `/legal/privacy/` … IT IS BLOCKER 2"* — on an item recording the + pass as done, pointing at a blocker the same page had just closed. Struck. +- **`npm run icons` was labelled `LOCAL ONLY`**, which this file defines as + *cannot run in CI*. It is pure Node and could; it is out of the build because a + build should not silently rewrite an artefact a human approved. Labelled + `REGENERATOR`. + ## 2026-09-02 (ap) — `/legal/privacy/` §Who can see it is cut to four plain statements and the mechanics move to the evidence file; the consent checkbox names SML Company Ltd; Q64 closes MOOT rather than answered **Two rulings from Pouya, 2026-09-02.** **(1)** §Who can see it **stays diff --git a/CLAUDE.md b/CLAUDE.md index 6b0dbff..0022cba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,7 @@ npm run check:intake # the form's field table vs the Lambda's — they are two npm run og:proof # every og:image resolves; every card headline IS its page's

npm run lighthouse # the performance budget. LOCAL ONLY — needs Chrome, not in CI npm run bio:pdf # re-renders the committed one-page PDF from /bio/. LOCAL ONLY +npm run icons # re-derives public/favicon.ico from the brand master. REGENERATOR npm run lint # eslint + prettier check npm run format # prettier — rewrite files in place npm run deploy # build + deploy from this machine (see docs/06) @@ -153,6 +154,17 @@ checklist, and **they are deliberately not wired into `npm run build` or either deploy path.** Do not describe either as gating a deploy: a check described as running where it cannot is the defect `AGENTS.md` Q22 turned out to be. +**`bio:pdf` and `icons` are REGENERATORS, not gates** — they rewrite committed +artefacts (`public/pouya-lajevardi-bio.pdf`, `public/favicon.ico`) rather than +checking anything, so they are run deliberately and their output is committed. +⚠️ **`LOCAL ONLY` above means a different thing for each, so do not read the two +labels as one.** `bio:pdf` **cannot** run in CI — it drives a browser. `icons` +is pure Node and **could**; it is out of the build because a build should not +silently rewrite an artefact a human approved, which is why it is labelled +`REGENERATOR` rather than `LOCAL ONLY`. It regenerates the favicon **only** — the touch icon +is hand-made and must stay opaque cream (`docs/reference/brand-assets.md` +§The icon set). + **`og:proof` and `check:intake` exist because two facts in this repo are deliberately duplicated**, and a duplicated fact needs a mechanism rather than a comment. `og:proof` compares each generated OG card's headline against its page's @@ -363,7 +375,7 @@ an operator to delete the three records that authenticate outbound mail — under the heading "Never delete". **A measurement is a claim about your instrument until you check the -instrument.** This has now cost six times, and the shape is identical every +instrument.** This has now cost eight times, and the shape is identical every time: a number that looks like a finding, from a probe nobody validated. - `timeout 60 ls "$DRIVE"` — **the command never ran.** `timeout` is not @@ -391,6 +403,20 @@ time: a number that looks like a finding, from a probe nobody validated. from the list. **A `grep -o` window count is not an enumeration** — to count occurrences of a string, iterate every match position, or `grep -o` the bare string with no context window. +- **An ink bounding box of `48x48` filling the whole 48 px favicon** — which + reads as *the regeneration blew the mark up to the full square*. The probe + identified ink as "differs from cream", which is **correct on a matted icon and + meaningless on a transparent one**, because `alpha = 0` pixels still carry RGB + `(0,0,0)`. **Nothing about the probe changed — the class of input did**, and a + probe cannot tell you that. Composite over a known ground first, so both things + compared are the kind of thing the instrument was built for. +- **A contrast ratio of `14.02:1` for the mark's darkest ink** — computed from a + raw channel value **without asking what alpha it is painted at**. Exactly one + pixel carried that colour and its alpha was 251; a `>= 250` filter let it + through and the ratio was then taken as if it were opaque. **It is a number + about a colour that is never painted.** Composite against the actual ground + before measuring contrast, and take "the ink colour" only from fully opaque + pixels. So before acting on a number: say what it is a number *of*; confirm the command actually ran and read its exit status; and check it against a second method that diff --git a/docs/06-deployment.md b/docs/06-deployment.md index 3a86f6b..39f0c14 100644 --- a/docs/06-deployment.md +++ b/docs/06-deployment.md @@ -395,8 +395,22 @@ Then invalidate `/*`. > reversing them puts 22 of 23 pages behind a 403 for as long as a CloudFront > deployment takes. -> 🛑 **TWO THINGS BLOCK THIS ENTIRE LIST AS AT 2026-09-02: ONE WAITING PERIOD -> AND ONE READ-THROUGH.** +> 🛑 **ONE THING BLOCKS THIS ENTIRE LIST AS AT 2026-09-02, AND IT IS A WAITING +> PERIOD RATHER THAN A TASK: Q60.** +> +> ✅ **THE READ-THROUGH IS COMPLETE — Pouya, 2026-09-02, and it returned ONE +> FINDING WHICH WAS NOT COPY.** `public/favicon.ico` shipped with no +> transparency; fixed and verified the same day (see **Favicon set complete** +> below). His read carried the approvals with it, in terms: the +> `/legal/privacy/` §Who can see it wording, the **SML Company Ltd** consent +> line, and `/med-arb/` **as shipped**. That discharges blocker 2 and every +> wording sign-off that had been routed into it. +> +> ⚠️ **THE COUNT NOW READS ONE AGAIN, AND THE EARLIER ONE WAS A DEFECT — READ +> THE REASON, NOT THE NUMBER.** It said ONE earlier on 2026-09-02 because an +> approval had gone **missing** from the list; it says ONE now because the pass +> that approval was routed into has been **done**. A tally cannot tell those +> apart, which is the point the note below has been making all day. > > ⚠️ *(The count has moved repeatedly in one day and the DIRECTION is the only > part worth reading — the number of moves is deliberately not stated, because a @@ -421,12 +435,12 @@ Then invalidate `/*`. > record is written and it does not call failure before **7 days**, so > **start it before anything else on this page.** It is the one blocker > that is a waiting period rather than a task. -> 2. **Pouya has not yet read every page against `AGENTS.md` §4.** The human -> pass — the other half of D20, and not delegable. **It is also where the -> §Who can see it approval now lands:** Pouya ruled on 2026-09-02 that the -> read-through *is* the approval and that nothing is to be held open waiting -> on a separate wording sign-off. The item under **Copy and claims** below -> carries what to read first and why. +> 2. ✅ **DONE 2026-09-02 — Pouya read every page against `AGENTS.md` §4.** The +> human pass, the other half of D20 and not delegable. It was also where the +> §Who can see it approval was routed, and his ruling that the read-through +> *is* the approval means that sign-off is now discharged rather than +> pending. **Sole finding: the favicon's opaque ground.** No copy finding on +> any of the 23 pages. > > ✅ **CLOSED 2026-09-02 — Q64, MOOT.** It asked whether anyone else holds the > AWS root password or its MFA device, because the page published *"has no @@ -650,12 +664,25 @@ the decision is re-readable rather than re-litigated. count has been wrong twice, and this line carried "eight" for a round after the comment itself had been corrected to nine (`adversarial-reviewer`, round 2). Read the list, not a number -- [ ] **Pouya has read every page against `AGENTS.md` §4.** The human pass. It is - the other half of D20 and it is not delegable — his reading is what the - per-step audit was traded for. - ⚠️ **START WITH `/legal/privacy/` §Who can see it. IT IS BLOCKER 2 IN THE - CALLOUT ABOVE, AND THIS READ *IS* THE APPROVAL** — Pouya ruled on - 2026-09-02 that nothing waits on a separate wording sign-off. Every +- [x] ✅ **DONE 2026-09-02 — Pouya has read every page against `AGENTS.md` §4.** + The human pass, the other half of D20 and not delegable — his reading is + what the per-step audit was traded for. **It returned one finding across 23 + pages and that finding was not copy:** the favicon shipped with an opaque + cream ground. **The `/legal/privacy/` §Who can see it wording, the + SML Company Ltd consent line and `/med-arb/` as shipped are approved by + this read**, per his ruling that the read-through *is* the approval. + ⚠️ **This does NOT discharge `claims-auditor`'s cutover pass**, which is a + separate item on this list: D20 traded the per-step machine audit for the + human pass **plus** one machine pass over the finished site, and one of + those two has now happened. + ~~⚠️ **START WITH `/legal/privacy/` §Who can see it. IT IS BLOCKER 2 IN THE + CALLOUT ABOVE, AND THIS READ *IS* THE APPROVAL**~~ — **struck 2026-09-02: + the pass is DONE, and an unstruck imperative on a ticked item told an + operator to begin a read this page also records as finished, pointing at a + blocker that no longer exists** (`adversarial-reviewer`, round 2). What it + said remains true of what happened: Pouya ruled on + 2026-09-02 that nothing waits on a separate wording sign-off, and he read + §Who can see it first. Every sentence in it changed three times that day — Q62's ruling, Q63's, then the ruling that cut it to **four plain statements** — and it is the only section on the site whose subject lives entirely outside this repository. @@ -972,7 +999,25 @@ the decision is re-readable rather than re-litigated. instead. A `Disallow` will not do it: a blocked URL can still be listed. Found by `adversarial-reviewer`, 2026-08-31 - [ ] Booking link works, including the no-JavaScript fallback — **conditional on R6**; booking is parked and `CONTACT.bookingUrl` is `null`, so nothing renders and this passes vacuously until a tool is chosen. **Nothing on `/contact/` mentions booking**, deliberately -- [ ] Favicon set complete +- [x] ✅ **Favicon set complete, and REGENERATED 2026-09-02 — it had shipped with + no transparency at all.** Pouya's read-through finding. All three frames + (16/32/48) declared a 32-bit alpha channel and then carried `alpha = 255` + on every pixel, the ground opaque cream — so the tab icon showed as a cream + rectangle on any dark tab strip. `public/favicon.ico` is now transparent, + regenerated by `npm run icons` from the committed master and verified + programmatically and by eye, on dark grounds and light. + ⚠️ **`public/apple-touch-icon.png` STAYS OPAQUE CREAM AND MUST NOT BE + "FIXED" TO MATCH.** The reason is a platform behaviour — iOS composites a + transparent touch icon onto black — **stated by Pouya on 2026-09-02 and not + re-tested on a handset**; the item directly below is where it would be. The + touch icon is byte-identical across this change. + ⚠️ **AND THE CHANGE IS NOT FREE ON DARK.** The maroon half of the ribbon + effectively drops out against a dark tab strip; the champagne half carries + the mark. **The figures are deliberately NOT repeated here** — they live in + `docs/reference/brand-assets.md` §The icon set, with the method, and a copy + on this page had already gone stale within a day by quoting the 32 px row + as if it were the general case (`adversarial-reviewer`, round 2). Read them + there. - [ ] Tested on iOS Safari, Android Chrome, desktop Safari/Chrome/Firefox - [ ] Tested at 320 px and at 200% zoom - [x] ✅ **THE 200%-TEXT NAV OVERFLOW IS FIXED, 2026-09-01 — THIS ITEM IS diff --git a/docs/reference/brand-assets.md b/docs/reference/brand-assets.md index 56374f7..3641fbf 100644 --- a/docs/reference/brand-assets.md +++ b/docs/reference/brand-assets.md @@ -5,9 +5,22 @@ is `CLAUDE.md`'s rule and `AGENTS.md` R14, and this file exists because the infinity mark was reconstructed wrongly and **two adversarial review passes could not catch it**, since the real artwork was not in the repo to compare against. -**Every measurement below is `[verified 2026-08-26]`** — computed with `sharp` -against the files in this repository, and re-derivable by anyone from the -commands given. Nothing here is quoted from an external source. +**The measurements here are computed with `sharp` against the files in this +repository**, and are re-derivable by anyone from the commands given. + +**Unless a section says otherwise, every figure below is `[verified +2026-08-26]`. Where a section carries its own stamp, that stamp wins** — the +render ladders are `[measured 2026-08-27]` and §The icon set is `[verified +2026-09-02]`. ⚠️ *This line has been wrong in both directions in one week: first +as a blanket 2026-08-26 that was already stale for the ladders, then as +"each section carries its own stamp" when five sections carry none. It is a floor +plus overrides because that is the only shape that covers every section without +mis-dating one* (`adversarial-reviewer`, rounds 1 and 2). + +⚠️ **Two things in §The icon set are NOT read off this repository and are +labelled where they appear:** the tab-strip colours used in the contrast figures, +and iOS's handling of a transparent touch icon. Neither is derivable from +anything committed, so neither is stamped as if it were. ## Files @@ -103,6 +116,138 @@ Passing an explicit `width` is load-bearing: without it Astro emits the untouched 2668 px master as the `` fallback — **1,146,406 bytes** — which any client without AVIF or WebP support would actually download. +## The icon set + +`[verified 2026-09-02 — every figure below read off the icon files in this +repository, EXCEPT the two external constants flagged inline]` + +| File | What it is | Ground | +|---|---|---| +| `public/favicon.ico` | 16, 32 and 48 px frames, each a PNG-encoded 32-bit RGBA image inside the ICO container | **Transparent** | +| `public/apple-touch-icon.png` | 180 × 180 | **Opaque cream `#faf7f2`, and that is deliberate — see below** | + +**The two grounds differ on purpose, and this is the line that stops someone +"fixing" it.** ⚠️ *The reason is an external platform behaviour, not a repository +fact, and it is recorded here as what it is:* **iOS does not honour transparency +in a touch icon — it composites it onto black**, so a transparent touch icon +ships a black tile on the home screen. Stated by Pouya in his ruling of +**2026-09-02** and not independently re-tested here; **it has not been checked on +a handset in this repository, and the place that does check it is the unticked +`docs/06` item "Tested on iOS Safari…".** The tab favicon has the opposite +requirement: a tab strip is dark for many readers, and an opaque ground shows +there as a visible rectangle around the mark. So the favicon is transparent, the +touch icon is matted, and **neither should be changed to match the other.** + +### Composition + +The mark spans **7/8 of the canvas width**, centred on both axes — 14/16, 28/32, +42/48, and 158/180 on the touch icon. That was measured off the icons as they +already shipped, so a regeneration reproduces the composition rather than +restyling the mark. + +### Regenerating + +``` +npm run icons +``` + +`scripts/icons.mjs` resizes `src/assets/brand/sml-infinity-mark.png` onto a +transparent square canvas and assembles the ICO container itself, because +`sharp` does not write `.ico`. **It writes the favicon only** and never touches +the touch icon. It refuses to run if the render source has no alpha channel, and +it asserts that the source is still byte-identical to the documented crop of +`sml-infinity-mark-master.png` — R14, so the icon stays traceable to committed +artwork. It verifies a **candidate** file and renames it into place only on +success, so a rejected build cannot replace a good favicon. + +**It also carries the halo test**, because the check that matters was living +only in prose here while the generator could not run it +(`adversarial-reviewer`, round 2). For each frame it takes every painted pixel +touching a fully transparent one and measures how many sit within 20 of cream: + +| | boundary px | near cream | share | +|---|---|---|---| +| correct, 16 / 32 / 48 px | 61 / 146 / 258 | 1 / 2 / 5 | **1.6 / 1.4 / 1.9 %** | +| haloed fixture, 16 px | 56 | 11 | **19.6 %** | + +The gate is **10 %**, roughly 5× clear of both. ⚠️ **The first version of this +guard inspected only partial-alpha pixels and MISSED the fixture completely** — +a knockout sets alpha per pixel and leaves **no partial alpha at all**, so there +was nothing for it to look at. The fixture is built by matting the mark on cream +and then knocking the ground out by alpha, which is the defect exactly; it fires +at 19.6 % and leaves `public/favicon.ico` untouched. **A guard that cannot see +the defect it is named for is worse than none**, and this one passed the real +icon while blind, which is the shape that ends a check instead of starting one. + +### Why the favicon was regenerated, 2026-09-02 + +It had **no transparency at all**: all three frames declared a 32-bit alpha +channel and then carried `alpha = 255` on every one of their 256 / 1,024 / 2,304 +pixels, with the ground opaque cream `rgba(250,247,242,255)`. Found by Pouya on a +read-through, confirmed by parsing the container directly. + +Three measurements stand behind the replacement. + +**1. The composition did not change.** Composited back onto cream, the new icon +reproduces the old matted one to within **1 of 255 on every channel of every +pixel** at all three sizes (mean delta 0.01–0.02, 0 pixels over a delta of 8). +So the regeneration did not restyle, rescale or reposition the mark. + +⚠️ **That is NOT a test for a halo, and an earlier draft of this file filed it as +one.** Composite-onto-cream returns ~0 whether the edge is correct *or* is a +cream-matted edge that has merely had its background knocked out — the second +case composites straight back to what it came from. Both branches pass, so the +test cannot discriminate. Found by `adversarial-reviewer`, 2026-09-02. + +**2. No halo — the measurement that does discriminate.** Read the RGB the +partial-alpha pixels actually carry. A cream halo means that RGB is near cream; a +correct export means it is the ribbon's own colour. Of the partial-alpha pixels, +**0 of 94 / 300 / 603** are within 12 of `rgb(250,247,242)`; the nearest is 13 +away, the mean distance is **174.0 / 178.3 / 177.0**, and the mean colour per +frame is about `rgb(118,86,74)` — maroon-brown, not cream and not black. + +**3. Legible on dark and on light, and NOT at the same cost.** ⚠️ *The ground +colours below are external constants, not repository facts: `#202124` is +Chrome's dark tab strip and `#f1f3f4` its light one, both `[observed +2026-09-02]`.* All ratios are computed on **composited** pixels, and the ink +colours are **fully opaque** pixels only — a ratio taken from a raw channel value +is a claim about a colour that is never painted, which is how an earlier draft +came to quote `14.02:1` for a pixel whose alpha is 251. + +| | darkest opaque ink | lightest opaque ink | vs `#202124` | vs `#f1f3f4` | +|---|---|---|---|---| +| 16 px | `rgb(70,33,33)` | `rgb(216,188,143)` | champagne **8.82:1** | maroon **12.58:1** | +| 32 px | `rgb(60,29,29)` | `rgb(234,214,172)` | champagne **11.28:1** | maroon **13.62:1** | +| 48 px | `rgb(61,26,26)` | `rgb(238,219,175)` | champagne **11.80:1** | maroon **13.86:1** | + +Whichever ground it sits on, one end of the ribbon carries the silhouette. **But +the other end does not merely dim — on dark it goes.** The maroon lobe +composites to **1.04–1.15:1**, and counting pixels that reach 3:1 against each +ground gives **23 / 87 / 189** on dark against **40 / 168 / 378** on light, at +16 / 32 / 48 px. **So about half as many pixels reach 3:1 on dark as on light.** +⚠️ *Those pixels are not absent — they are painted and fall below 3:1, and the +mark's full outline is still there, dim. An earlier draft said "roughly half the +mark's visible pixels are absent", which is neither what was counted (pixels at +`alpha > 0` are 109 / 388 / 805) nor what a render shows* — `adversarial-reviewer`, +round 2. The previous opaque-cream icon was still more legible on dark. + +**That is the price of the change and it was accepted, not overlooked.** What was +bought is the removal of a cream rectangle from every dark tab strip and from +white. Raised by `adversarial-reviewer`, 2026-09-02, against a draft that +recorded the change as costless. + +**The obvious alternative is an icon pair keyed on `prefers-color-scheme`, and it +was NOT ruled out on measurement.** ⚠️ *An earlier draft dismissed it on a +mechanism that is wrong: it said the query keys off the page's colour scheme. It +does not — it reports the user's system or browser preference, and this site +declares `color-scheme` nowhere (`git grep -n color-scheme -- src`, exit 1), so +it would resolve to the same preference that makes the tab strip dark.* The real +objections are **untested here** and are recorded as such: `media` on +`` is unevenly supported for raster icons, a browser theme can +be set independently of the system preference, and it doubles the artefact +`npm run icons` has to keep in sync. **If this cost is ever revisited, that is +the option to test** — do not re-dismiss it on the reason struck above. + ## The colours are the artwork's, not the palette's `tokens.css` is not involved. The ribbon carries its own gradient and it is diff --git a/package.json b/package.json index da1dc91..1d3ef8b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "lighthouse": "node scripts/lighthouse.mjs", "og:proof": "node scripts/og-proof.mjs", "check:intake": "node scripts/check-intake.mjs", - "bio:pdf": "node scripts/bio-pdf.mjs" + "bio:pdf": "node scripts/bio-pdf.mjs", + "icons": "node scripts/icons.mjs" }, "dependencies": { "@astrojs/mdx": "^7.0.8", diff --git a/public/favicon.ico b/public/favicon.ico index 2675521b24a111bfad887b1a6f8091ea8aeb73bf..0932c259a2c05d00a2ce2037cab83471a2d120af 100644 GIT binary patch delta 5249 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 z z>J!&`vm&Im8gnG&ca>$G+;=Yb*Xy@e3zikuo(%Y;Tc7dCZE?T&-`5}1y#C#-X_e_( z?DW;T`0~eVr>;JEXL|C*L+73Ydss#0Dp`gJyif3+%b>E`deYX_LLN_ke%)Sw{>sDc zyR#p@IoM@;{%Xs9_Z}N@^^+;(h~pj1%!}?%UQVt}6?9{XRBV>6iZ1$+qrV z50B{SR@S65R<3nkUSE(a|4-oO^xr2M?!LIy6`!?QNAj2V(HBd_xe}hJxK%CEF?4G3 zTd=T0cM)xKTyTB*?QHIyw(hI7>|SN=sQI6DIlX-So7$@N(8__cw9Y?l?MKM=T4p96yDY9dY zBkRPfV>i4a7UnGbA!XzvvgTRF$xAC|&y}?}ef8+_%bsPMZok{}aqB(c0e`u3`?Ei{r<~^0)T5fGJ zTEm+@LxyXkfvH=Lq1SU!>1C5@zue#auy~8XKcf|0YHfRV=zNtrkhkvWipvu_bXOkD zjns*#DY)FXGNir!eCqS(WnDFXDt6WmPbz1#SuBsR3kvM}lIwg@M=AYsmhsL#9h)~c zKD%rhV_zn+|Manv3kTZ^#5;dF@?I!=rE>M?(LJ?)m(Mt#I&mq_-tBoG9&Ky=zL@c9 z=z-4@j+g5R3sy}q^5ivm@$zU?=+VN|h9;vq85J{@zb$_mR^<2NPEO>y8ZDRT&U%&E zo^S6R3e!6I*IUfW|4M>&$%JF4m@C%LOEtUTT9cP@eCi4-?K=+d=39Hz|2^d6k&wBm zzqMof4Qu+gcQbo(Mn%Q{$KUt9w<>-6 zd;8qKufMEa_%{0r?-sLbmv7r&|NT+gmVr%d#olnYj@Mo(X=!VOALeCs-M+JPNB4zq z+i%?vc(iHGoR*BJtEJ5cR=-}Qd3wLswTBgaZ$BKoUneMcdAYLrb_ILo+6(n+?2Hl; zYj#9>9572vNL6UY&#}y2=!VrU3k+?fVXc2Wf35nyd2$xq37?Pm)*r6wQQY5gBPH7}aaPl+M>&(64s#S2E^v}` zT(q8@^Y-4#3R}t2RJE(F?jGEeHfLRO@zRUG!mprO!JmERY^k-`Y?DsYKB03ess&1F zlY%#16iHhaSh}3y=}X}qx?9pNws1yB_Z*9T^Um)ZIp>pWCwg`j$?s z3nulpia!$+Bt3U`PW0v!w~=C7uq))~v!mS`#dgnD7c1MkeeoYR#;4amSAKkA_;!}s zt>}4|Z{E___kPK4=fjN)9CBQziq#}HyzY_TIbYiU<4%ny)3{e^XuWtI+P*G#)$YTx zsVc`GZQF3V@MQtRbG{>w)V%Ul&)rFRZ@k>fCLPGxep2|O(QMa)9}RBJi(9^XZR5LdVo_4^ zfq^ql9X`~2<$mAdCr)ST3mB|rWeWw?Y!?uXNX&LVFR=I3=dNYDxQZqv70k~(k~Uq{ zBkin1mJRRBt2sq{KR>SdBhl}DX@#!e!mz0xCw~S=c|Wdn_C1I)2-Pv zF5LW9G$YOaZTS{E_oZh<{uisf*&g5euKcn3cNNp1#+-*X2i1)qnf*Pt^l<&Y81HZH zGk5gv7C%(6#Ou-B?6U<7T>RzNxDL?ftQb+iZ+O!TH^37&U4AmeKGs_RoK}DH@Hj*JaOJl zBXRNM#eFYt?VR`S+dhGs3ql)~eUg*>xUoKGW88wtG5-u%!~5GVU%T_6@Zq{`VnzPT zC(EsUkkZq%=7`0qPn#<5URxvYaB^u5ld8}j*Pn0Q`qMgBH2v4oJpVeI?Pf{!Yi_-z za;dX>bvJ$r654JHI09%7xh*JR|}d=E`2IH+uNA?q189E#k+M&a7H# zzae>}X#Ki4w=2IypDb+&sy1$&74&p^H9t?okLPKOocrnvObvUiLbYD)_YasS+^&@@ z&;N;U@k+bXj~_iYu?zV+eb4LDwjOK_C7j!?O%V+Uu9+szzbRwYsYP4;JiTifXQe;g z^RmM7#Ye+WYBG!lOb>YKxgW%H2wjt&IYrHP@_7!IdakLU0y*Q}+ZoTJLn4Ka{gp3E zKby#tEqAn~e}X9=@bIS{v7EG;R?LQTE>v)Ho}9K3GszN~ip z>ksdrU-|Cz@rjbpMZeuQ|IW43U*mwd(Y&4C?B5({RSrw}ASH8Whv4zl)Iz67$>UR| z6wGP6yDrvZDu=_!2-g(-MX_2g8~K3QdYF?vH6yFlS!}K@$j#@>?W^~itSx71ocD9z z(rvuQr&loJWSf39zeQJ&L4EXgHnwi@!!_L!c}I@+SeCGHG%}nqeK*JYO=w8M`qkWb z&(2=BAwzJW1h$09?TlZ7j}Z9z$r9L z$8JxprDZ*zrK+J03uoQ)hfCJBrktPb@p-e`iHmm&%mdiculvaeM6<88{Jfz4V0PWV&gd%uC?)0&i(kR>{x|SOlXYE z&Ccr=YL>HjHZpdU6}x#&(9bFoHvF)F^}AG7!MSe|Q^M9fVAi-;d`iXi=*{8>2ZG#+ z9VM!wgC49~?lx(oOZ7(ins3qv!U9?2)Z8-kP9FRHaCv==b%pF#jsoi@1%B-(N9H=O zvSd@5D@HM)Z zzC_tie|p?@KHaTT|MXl83bo5!=)YR>ef!G1J#yX?L%0{6t9ERi7cSQxFwa)}8khBj zIHAUMyDE0Pzs&9)Dr-}8#^u&OE7hlUb>F$ZpI&@1Sibj;h5C=;760mU#Kn0oMAQf` z_q3XoV(_?D^V6TQNq!5Kv$wHT8UA?k@rhGF@6=EKbYzh{V_<}TXc zo@3mo)2QW`p%d4l{OR_^GHs^4rPl*yNtx&UnZ40+*^&?bi=8uc9y}_n)u|AT5mB`| z92Mqd;E;8JwJtD7;qX(9Qcr_V^@_IJ{XbriUpH@ZTus}yRZcJR@3zH?1fHFGdSm)k zE}4nK%Uxx99?p}lus$JOpI7qd=EicHr&kvk&hPHC7R=DL>tDAjfkn<(lt&<|dXCz( zW}C^^U#41fxXd=<3pQju`K+4Z`|6+zQ8W3M@+Ji=C|T`P{CZ6+=i=UM4WUOXyuQ{S zeZ5YG$4=|$iPP>Czc=x(XWMn*YEav)HwqVA9>>b}VCHuLr**}$2v+T2AOLxhFYq>oB6`c2$E({#b9kkDnm z3LIZ^ZyWqcs(--m?6YQK*PE0nPnQPtCo@eB)fUXmZ=19_ZpVWR&c0sOUDnYLlXDxL zH{C99a+Z?Gm7HT7dR)~#S>>0E)29E4OW5UH532G>aV}YG(sAt+^TVk(F1PTi`})3E z8_zF!%E?EiOvoiN@ZgqrU#jnFzt3a7+NtowtuvH~nH>l*A*keJ_t?T9QbFpjq zbu=4aWvnhOeG#~LnM+{8EMwLtkG{S+UuG@v;3=^^aJDDp%5;&BxAWC`OIc*^wl9+4 z7ZG$g8Jd4*z1|$3slvI|#kp&CH1FVM-0|jZD@)OV(slMpHg^lJ-e&!JG_rmQXM6gT zk0uTG`ob(Gwa8@1EYrvce!a%cC}_z-pVvxLZ*|^3NKtMGmH#b1%>2 z^Ecx*UvzGf&Po2GhVwK!xfLXBgf$L2YE2V0|6E{e(y=Q{@2$9z#?o&-s|D}h<&Uv^ z-E;X$zC}R!&icb_8=jqG?Y^{1&2dFwy@{XXd*|TFm*zJgoNO*z5;*(Kf@NIMx4!In zwQADEcN+eD2i98Ths1n8eE8R+%+@S5POeGI7yRGP_`vj9poqy2qfNn~Pv2K>Dm-M>8mHm#nV&bjSV+)Oc>6A`We39#QfZ?mY?UjbD zcQ~B=U%&mRJayuKy#w#h7ghLPJ+bFSbCXBbs*LluZhN$CQ=7Q)WW~LiUExbP0l+XkKVXg2q diff --git a/scripts/icons.mjs b/scripts/icons.mjs new file mode 100644 index 0000000..327ec9e --- /dev/null +++ b/scripts/icons.mjs @@ -0,0 +1,278 @@ +/** + * Regenerates `public/favicon.ico` from the committed brand master. + * + * LOCAL ONLY, like `bio:pdf`. Not wired into `npm run build` or either deploy + * path — the icons are committed artefacts and this is what re-derives them. + * + * Writes the favicon ONLY. `apple-touch-icon.png` is deliberately not touched + * and must stay opaque — `docs/reference/brand-assets.md` §The icon set. + */ +import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const MARK = `${root}src/assets/brand/sml-infinity-mark.png`; +const MASTER = `${root}src/assets/brand/sml-infinity-mark-master.png`; +const OUT = `${root}public/favicon.ico`; + +/** Sizes carried in the container, ascending — the order BaseLayout declares. */ +const SIZES = [16, 32, 48]; + +/** + * The mark spans 7/8 of the canvas and is centred on both axes. Not a taste + * decision at this point: it is the composition already shipping, measured off + * the previous icon at all three sizes (14/16, 28/32, 42/48) and off the touch + * icon (158/180). Regenerating for alpha must not also restyle the mark. + */ +const INK_FRACTION = 7 / 8; + +/** `--cream` — the ground the previous icon was matted against. */ +const CREAM = [250, 247, 242]; + +const die = (msg) => { + rmSync(`${OUT}.candidate`, { force: true }); + console.error(`icons: ${msg}`); + process.exit(1); +}; + +/** + * R14 — the icon must be traceable to the artwork in this repository, not to a + * file on someone's disk. The render source is a tight crop of the master, so + * assert it still IS that crop before deriving anything from it. + */ +async function assertProvenance() { + const mark = await sharp(MARK).metadata(); + const crop = { left: 159, top: 646, width: 2668, height: 1704 }; + if (mark.width !== crop.width || mark.height !== crop.height) { + die( + `render source is ${mark.width}x${mark.height}, expected ${crop.width}x${crop.height}`, + ); + } + const [a, b] = await Promise.all([ + sharp(MASTER).extract(crop).raw().toBuffer(), + sharp(MARK).raw().toBuffer(), + ]); + if (!a.equals(b)) + die('render source is no longer the documented crop of the master'); + console.log( + `provenance: ${crop.width}x${crop.height} at (${crop.left},${crop.top}) of the master — identical`, + ); +} + +/** + * The whole point of the regeneration. A source without alpha would mean + * deriving a mask from the cream ground, which is a different and lossier job — + * so fail rather than silently ship a matted icon again. + */ +async function loadMark() { + const meta = await sharp(MARK).metadata(); + if (!meta.hasAlpha) + die( + `${MARK} has no alpha channel — cannot export a transparent icon from it`, + ); + const { data, info } = await sharp(MARK) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + let transparent = 0; + for (let p = 3; p < data.length; p += 4) if (data[p] === 0) transparent++; + if (transparent === 0) + die(`${MARK} declares alpha but every pixel is opaque`); + console.log( + `source: ${info.width}x${info.height} alpha, ${transparent} fully transparent px`, + ); +} + +/** + * Resize onto a TRANSPARENT canvas. sharp premultiplies around the resample, so + * the ribbon's anti-aliased edge blends toward its own colour rather than + * toward the RGB sitting under alpha 0 — that is the cream halo this change + * exists to remove, and it would come straight back with a matted background. + */ +async function frame(size) { + const w = Math.round(size * INK_FRACTION); + const png = await sharp(MARK) + .resize({ + width: w, + kernel: 'lanczos3', + fit: 'inside', + withoutEnlargement: false, + }) + .toBuffer(); + const { height: h } = await sharp(png).metadata(); + if (h > size) die(`size ${size}: mark is ${w}x${h}, taller than the canvas`); + const left = Math.round((size - w) / 2); + const top = Math.round((size - h) / 2); + const out = await sharp({ + create: { + width: size, + height: size, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .composite([{ input: png, left, top }]) + .png({ compressionLevel: 9, effort: 10, palette: false }) + .toBuffer(); + console.log( + ` ${size}x${size}: mark ${w}x${h} at (${left},${top}), ${out.length} B`, + ); + return out; +} + +/** ICO container: 6-byte header, one 16-byte directory entry per frame, then the PNG payloads. */ +function buildIco(frames) { + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); // reserved + header.writeUInt16LE(1, 2); // type 1 = icon + header.writeUInt16LE(frames.length, 4); + + const dir = Buffer.alloc(16 * frames.length); + let offset = header.length + dir.length; + frames.forEach(({ size, png }, i) => { + const e = i * 16; + dir[e] = size; // width — a byte; 0 would mean 256, which SIZES never is + dir[e + 1] = size; // height + dir[e + 2] = 0; // palette size — 0 for truecolour + dir[e + 3] = 0; // reserved + dir.writeUInt16LE(1, e + 4); // colour planes + dir.writeUInt16LE(32, e + 6); // bits per pixel + dir.writeUInt32LE(png.length, e + 8); + dir.writeUInt32LE(offset, e + 12); + offset += png.length; + }); + + return Buffer.concat([header, dir, ...frames.map((f) => f.png)]); +} + +/** + * Re-read the container FROM DISK and decode each frame, rather than inspecting + * the buffers we just built — a check that reads its own inputs proves nothing. + * (It is still `sharp` decoding `sharp`'s output, so it is not a second + * instrument. The independent reads are in `docs/reference/brand-assets.md`.) + */ +async function verify(path) { + const buf = readFileSync(path); + const count = buf.readUInt16LE(4); + if (count !== SIZES.length) + die(`container declares ${count} frames, expected ${SIZES.length}`); + for (let i = 0; i < count; i++) { + const e = 6 + i * 16; + const size = buf[e]; + const len = buf.readUInt32LE(e + 8); + const off = buf.readUInt32LE(e + 12); + if (off + len > buf.length) + die(`frame ${i}: range ${off}+${len} exceeds ${buf.length} B`); + const { data, info } = await sharp(buf.subarray(off, off + len)) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + if (info.width !== size || info.height !== size) + die(`frame ${i}: decoded ${info.width}x${info.height}, dir says ${size}`); + const corners = [ + [0, 0], + [size - 1, 0], + [0, size - 1], + [size - 1, size - 1], + ]; + for (const [x, y] of corners) { + const a = data[(y * size + x) * 4 + 3]; + if (a !== 0) + die(`frame ${size}: corner (${x},${y}) has alpha ${a}, expected 0`); + } + /* + * THE CORNER AND TRANSPARENCY CHECKS CANNOT SEE A CREAM HALO. A frame whose + * edge was matted against cream and then had its background knocked out has + * clear corners, transparent pixels and opaque pixels, and passes every one + * of them. What distinguishes it is the colour the edge blends TOWARD. + * + * ⚠️ AND IT IS THE BOUNDARY, NOT THE PARTIAL-ALPHA PIXELS. A first version + * of this guard inspected only pixels at 0 < alpha < 255 and MISSED a + * purpose-built haloed fixture entirely, because a knockout sets alpha per + * pixel and leaves NO partial alpha at all — 0 such pixels in the fixture. + * A guard that cannot see the defect it is named for is worse than none. + * + * So: take every painted pixel that touches a fully transparent one, and + * measure how many sit near cream. Measured on this artwork — correct + * frames 1 / 2 / 5 of 61 / 146 / 258 boundary pixels (1.4-1.9%); the haloed + * fixture 33 of 115 (28.7%). The gate is 10%, roughly 5x clear of both. + */ + const NEAR_CREAM = 20; + const HALO_SHARE = 0.1; + const alphaAt = (x, y) => + x < 0 || y < 0 || x >= size || y >= size + ? 0 + : data[(y * size + x) * 4 + 3]; + let clear = 0; + let ink = 0; + let boundary = 0; + let boundaryNearCream = 0; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const i = (y * size + x) * 4; + const a = data[i + 3]; + if (a === 0) { + clear++; + continue; + } + if (a === 255) ink++; + const onEdge = + alphaAt(x - 1, y) === 0 || + alphaAt(x + 1, y) === 0 || + alphaAt(x, y - 1) === 0 || + alphaAt(x, y + 1) === 0; + if (!onEdge) continue; + boundary++; + const d = Math.max( + Math.abs(data[i] - CREAM[0]), + Math.abs(data[i + 1] - CREAM[1]), + Math.abs(data[i + 2] - CREAM[2]), + ); + if (d <= NEAR_CREAM) boundaryNearCream++; + } + } + const share = boundary === 0 ? 0 : boundaryNearCream / boundary; + if (clear === 0) + die(`frame ${size}: no transparent pixels — the ground is fully matted`); + if (ink === 0) + die(`frame ${size}: no opaque pixels — the mark did not render`); + if (boundary === 0) + die(`frame ${size}: no boundary pixels — cannot test the edge colour`); + if (share > HALO_SHARE) + die( + `frame ${size}: ${boundaryNearCream} of ${boundary} boundary pixels ` + + `(${(share * 100).toFixed(1)}%) sit within ${NEAR_CREAM} of cream — ` + + `the edge was matted against the ground before the ground was removed`, + ); + console.log( + ` ${size}x${size}: ${clear} transparent, ${ink} opaque, corners clear, ` + + `edge ${(share * 100).toFixed(1)}% near cream`, + ); + } + console.log(`verified ${path} (${buf.length} B)`); +} + +await assertProvenance(); +await loadMark(); +console.log('frames:'); +const frames = []; +for (const size of SIZES) frames.push({ size, png: await frame(size) }); + +/* + * Verify a CANDIDATE file, then move it into place. Writing OUT first and + * verifying after would mean a failing check exits 1 having already replaced a + * good favicon with the one it just rejected — and nothing downstream re-checks, + * because this script is deliberately outside the build and both deploy paths. + */ +const candidate = `${OUT}.candidate`; +writeFileSync(candidate, buildIco(frames)); +console.log('verify:'); +try { + await verify(candidate); +} catch (err) { + rmSync(candidate, { force: true }); + throw err; +} +renameSync(candidate, OUT); +console.log(`wrote ${OUT}`); diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 9959190..63b93f8 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -56,7 +56,11 @@ const { preloadSerifItalic = false, ...seo } = Astro.props; /* No SVG favicon. The mark is a shaded ribbon, not flat vector paths, so there is no honest SVG of it to serve — see InfinityMark.astro and AGENTS.md Q38. The .ico carries 16/32/48, and is what crawlers request - at the root regardless of what is declared here. */ + at the root regardless of what is declared here. + + The .ico is transparent and the touch icon is opaque cream ON PURPOSE — + do not change either to match the other: + docs/reference/brand-assets.md §The icon set. */ }