Skip to content

site(covers): cover-spec gate for posts dated from 2026-11-17 + build-time dark cover variants switched by theme - #347

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/pm-dispatch-objectos-ju9td1-issue-343
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/pm-dispatch-objectos-ju9td1-issue-343

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Closes #343

Implements decisions 3 (the gate part) and 4 of the visual-style ruling on #340 (comment 6570301123, maintainer 2026-11-02). AGENTS.md is untouched: the written spec lands in #344.

What changed

1. Cover spec gate: scripts/cover-spec.mjs (pnpm cover:spec, runs in pnpm build right after svg-text-fit).

2. Dark variants: scripts/cover-dark.mjs (pnpm cover:dark, runs in pnpm build before astro build and in pnpm dev).

  • It writes cover*.dark.svg beside each light SVG cover. The files are build output and gitignored, and stale ones are deleted on each run.
  • The palette lives in scripts/lib/cover-colors.mjs and works in two layers:
    • TOKEN_MAP copies the site's light-to-dark token pairs from the two :root blocks in Base.astro, plus white mapped to the dark --surface.
    • Every other colour (the covers hold about 370 distinct literals) goes through a contrast-preserving luminance inversion, Y' = K/(Y+0.05) - 0.05. This keeps every WCAG contrast ratio the same size with the order flipped. Hue and chroma are kept, and chroma is cut only as far as the sRGB gamut requires.
  • The script rewrites fill, stroke, stop-color and color in style blocks, in style attributes and as presentation attributes. It never rewrites inside a text node. flood-color is left alone, so shadows stay shadows.
  • If the root has no fill, it gets the dark counterpart of black. Otherwise text with no fill would inherit black on the dark ground.
  • A colour spelling the script cannot read fails the build.
  • Covers whose ground is already dark (self-hosted-ai-app-platform, 2 files) get no variant and keep the --cover-filter.
  • Built-in check: each text's contrast against the shape under it. The dark variant may not fall below min(light contrast, 3:1), with 2% slack for 8-bit rounding. Over 1,670 texts, the worst measured ratio of dark to light contrast is 0.988.

3. Templates.

  • Files: ArticleCard.astro, the BlogPost hero, and the card images in MarketingHome.astro and ClusterPage.astro. They find the variant through src/lib/cover-variant.ts, which globs the dark variants and keys them by the post's frontmatter cover path. That is the same postCoverFile rule og-images uses.
  • With a variant, a template renders two lazy img elements, cover-light and cover-dark. CSS hides the one that does not match :root[data-theme].
  • The dark one takes filter: none: the site(style): phase-0 quick wins — font-stack order, dark CTA, dark cover dimming, prose measure, CJK title line-height, tabular numerals, hamburger at 1024 #341 --cover-filter dimming is removed wherever a variant exists. The token stays for raster covers and the two dark-ground SVGs.
  • svg-text-fit skips *.dark.svg files, so their boxes are not counted twice. Its parse helpers are now exported for the two new scripts, and checkSvg carries each text's and each shape's computed fill.

Deviations and trade-offs

  • Switching uses [data-theme] alone, not a separate prefers-color-scheme media query. The head script in Base.astro sets data-theme before first paint, from the saved choice or else from prefers-color-scheme. The whole dark palette keys on that attribute. A second media-query rule would show a dark cover on a light page whenever that script cannot run.
  • The hero and the blog index's feature card are now lazy, not eager. A hidden image is skipped only when it is lazy, so an eager pair would download both. The visible cover is in the first viewport and is requested at first layout, and an SVG cover is a few KB. fetchpriority="high" is kept on the hero pair. SVG covers without a variant and raster covers keep eager.
  • Inverting for contrast flips emphasis. A dark panel inside a light cover (for example the 88% panel and the "What you can't see" card) becomes a pale panel on the dark cover. Text stays as readable as in the light cover, but the panel now reads as the light element. The covers redone under the spec will be single-ground, where this does not come up.

Verification (every gate below was run on the committed tree)

Gates

Gate Result
pnpm content:lint exit 0: content lint passed (399 files, 46 glossary terms checked)
pnpm check (verify lock, slot www-cov-343) exit 0: 0 errors, 0 warnings, 0 hints (166 files)
pnpm build (verify lock) exit 0. cover-spec: OK — 0 covers of posts dated on or after 2026-11-17, 88 grandfathered by date; cover-dark: OK — 86 dark variants … 2 covers already dark, skipped; 1670 texts keep their contrast; 105 share images rendered; content lint --dist passed
pnpm seo:smoke exit 0: 954 HTML pages, 4 inline script digests CSP-checked

Gate passes on current main: see the build row. All 88 covers are grandfathered.

A synthetic new-dated cover fails. A temporary post zz-synthetic-cover-test, dated 2026-11-20, had a 64px title and a 14px caption:

content/blog/zz-synthetic-cover-test/cover.svg:4  [min] TEXT "a 14px caption no card can show" is set at 14px; no text may be under 28px
cover-spec: FAIL — 1 cover breaks the spec (covers breaking each rule: min 1), 0 unreadable, 0 stale LATE_QUEUE entries (1 cover of posts dated on or after 2026-11-17, 88 grandfathered by date). Spec: at most 2 font sizes, title at least 56px, nothing under 28px, at most 8 TEXT runs, at most 6 fill colours.
EXIT 1

(TEXT stands for the element name, spelled without brackets here.) The LATE_QUEUE path was checked on the same synthetic post:

  • With an entry at the post's date, the gate passed with 1 excused by LATE_QUEUE.
  • With the post re-dated to 11-21 and the entry still at 11-20, it failed. The finding came back, and the entry was named as stale.
  • An entry matching no post failed with 1 stale LATE_QUEUE entry.

The entry and the directory were then removed, and the gate went back to 0 checked and 88 grandfathered. A second synthetic file triggered the other four rules: fills, title, runs and sizes each fired with the element named.

--report: 88 of 88 covers break the spec. Every cover breaks min, sizes, fills and runs, and 80 break title. One row per cover (runs, sizes, title px, runs under 28px, fills, rules broken) is ready for the redo card. First rows:

cover                                                       date        runs  sizes (px)          title  under  fills  broken
agent-operable-apps-mcp-tools-user-permissions/cover-en.svg 2026-10-26  23    50/19/15/14/13/12   50     20     13     min,title,sizes,fills,runs
ai-ontology-open-protocol/cover-en.svg                      2026-06-12  18    34/22/18/16/15      34     17     12     title,min,sizes,fills,runs
why-ai-agent-pilots-fail-four-layers/cover.svg              2026-06-16  16    120/34/18/16/15/14  120    14     11     min,sizes,fills,runs

Dark-mode cover luminance. Chromium, 1440, dark theme. "Before" is reproduced on this build by forcing the light image with main's brightness(.85). Contrast is the mean against the dark --bg #101713.

Target State n mean Y median Y vs --bg
blog card light, no filter (before #341) 6 0.822 0.914 15.1:1
blog card light + brightness(.85) (main) 6 0.571 0.633 10.8:1
blog card dark variant (this PR) 6 0.070 0.018 2.1:1
home card light, no filter 3 0.860 0.915 15.8:1
home card light + brightness(.85) (main) 3 0.597 0.634 11.2:1
home card dark variant (this PR) 3 0.037 0.018 1.5:1
post hero light, no filter 1 0.851 0.897 15.6:1
post hero light + brightness(.85) (main) 1 0.590 0.623 11.1:1
post hero dark variant (this PR) 1 0.047 0.019 1.7:1

Network log: one variant per theme. Chromium, 1440. Each page was scrolled top to bottom so every lazy image reached the viewport. The table counts finished requests for cover files.

Theme Page Pairs in DOM Light SVG fetched Dark SVG fetched Raster fetched
light /en/ 3 3 0 0
light /en/blog/ 51 52 0 16
light post 1 1 0 0
light cluster /en/ai-native-app-platform/ 1 2 0 4
dark /en/ 3 0 3 0
dark /en/blog/ 51 1 51 16
dark post 1 0 1 0
dark cluster 1 1 1 4

The one extra light SVG on the blog index and the cluster page is the self-hosted-ai-app-platform cover. It is already dark and has no variant. The computed filter on every dark variant is none.

8 locales × light/dark × 1440/390 on home cards, the blog index and a post hero: 96 screenshots, horizontal overflow 0 on all 96, 0 broken covers, 0 covers shown in the wrong theme. I looked at the contact sheets and at native-size light/dark pairs of six covers, including the ones with dark panels and gradient bars.

Not in this PR

🤖 Generated with Claude Code

https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr

Decisions 3 (gate) and 4 of the #340 visual-style ruling.

- scripts/cover-spec.mjs runs in `pnpm build`. Every cover*.svg of a post
  dated on or after SPEC_FROM (2026-11-17) is held to: at most 2 font
  sizes, title at least 56px, nothing under 28px, at most 8 text runs,
  at most 6 fill colours. Existing covers are grandfathered by the post's
  date. SPEC_FROM is the day after the last queued slot (11-16), because
  the publish queue re-dates its six pre-spec posts to their publish days.
  A queued post that slips past it takes a dated, named LATE_QUEUE entry;
  the list is empty, and a stale entry fails. --report prints one row per
  cover and never fails (88 of 88 break the spec today).
- scripts/cover-dark.mjs writes cover*.dark.svg (gitignored) beside each
  light SVG cover, before `astro build` and in `pnpm dev`. Site tokens map
  to their dark values. Every other colour goes through a luminance
  inversion that keeps WCAG contrast; the script checks this for all 1,670
  texts. The two covers with a dark ground are skipped.
- ArticleCard, the BlogPost hero, and the home and cluster cards render
  both covers lazily and hide one by :root[data-theme]. Only one is
  downloaded per theme. The dark variant takes no --cover-filter.
- svg-text-fit skips *.dark.svg, exports its parse helpers, and carries
  computed fills in checkSvg.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 06:37
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

Verdict: ACCEPT. I reviewed this against GitHub rather than trusting the report. It implements decisions 3 (gate) and 4 (dark variants) of the ruling on #340, comment 6570301123. The maintainer named the gate, so the default-no rule for new gates does not apply.

Item Reading
Shape get_files lists 13 files: new scripts/cover-spec.mjs, scripts/cover-dark.mjs, scripts/lib/cover-{colors,inventory}.mjs, src/lib/cover-variant.ts; card and hero markup in ArticleCard, BlogPost, MarketingHome and ClusterPage; build wiring in package.json, scripts/dev.mjs and .gitignore. No governed path.
Premise measured --report shows all 88 existing covers break the spec (min-size 88, title 80). In dark mode, light covers sat at 15.1:1 against the page, and 10.8:1 under #341's dimming.
Gate SPEC_FROM = 2026-11-17. The publish queue re-dates its six pre-spec posts up to the 11-16 slot, and 「暂时只发不写新的」 means no new posts arrive before then. Results: main shows 0 checked and 88 grandfathered (exit 0). A synthetic post dated 11-20 with a 14px caption fails (exit 1) and names the file, line and rule. LATE_QUEUE ships empty and fails on stale entries, so it cannot outlive the queue.
Dark variants 86 variants are generated at build time and gitignored. Text contrast is preserved for all 1,670 runs (worst ratio 0.988). Card luminance against the page drops from 15.1:1 to 2.1:1. Network log: in light theme 0 dark SVGs are fetched, and in dark theme only the dark ones. The seat looked at the en 1440 dark blog index.
Gate check-runs Build and deploy = success on head 82cfa67; Post-deploy probe = skipped on the PR, as designed (#337). Mergeable clean. Local runs: content:lint 0, check 0, build 0, seo:smoke 0.
Accepted deviations The theme switch keys on [data-theme] only, which Base.astro derives from prefers-color-scheme before paint. The hero and feature card are now loading=lazy where a variant exists, and keep fetchpriority=high.
Out-of-scope dispositions Inverted emphasis panels on dark variants: Acceptance notes; they go away when the covers are redone. The 88 non-compliant covers: carrier is a future cover-redo card, to be filed when writing resumes.
Landing Relay automerge_enable, then the merge queue, then a confirmed push deploy plus the post-deploy probe. #343 closes via Closes #343.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

site(covers): cover authoring gate for new posts + build-time dark cover variants switched by theme

2 participants