🔍 fix: apply the code review — two visible regressions, and the gates behind them

Nine review angles, fourteen verified findings, all but the byte-table
generator applied. The two that mattered most were regressions of mine:

- a:hover repainted button-styled anchors, and in dark theme --accent-text
  equals --accent-solid — hovering the hero CTA drew its label in its own
  background colour. Verified invisible before (computed color == computed
  background) and distinct after: 10.39:1 dark, 6.90:1 light. The buttons and
  the skip link now re-assert their label colours on hover.
- Footer links had lost every non-hover affordance: the text-decoration:none
  carve-out plus body-coloured links left a 2.37:1 shade difference as the
  only cue. The carve-out is deleted — .nav-links a and .brand already
  declare none themselves — and the accent rule is back on the footer.

Also on the page: #ref-filter, the one text input, moves to --border-ui (it
still had the 1.68:1 border the token's own comment calls decorative);
focusable code surfaces get the --accent-on-code ring at -2px offset, inside
the .code overflow clip; #output .out-err drops #ff8095, the last surviving
colour of the deleted indigo palette; the two rgba(255,106,126) washes become
color-mix over --err-line so a grep for the token finds them.

The theme machinery loses a whole block: the dark media query is guarded with
:not([data-theme="light"]), so an explicit light toggle falls through to the
bare :root palette and the 21-token hand-copy in [data-theme="light"] is
gone. Verified in all four system/toggle combinations.

The page stops contradicting the repo: it claimed cereale/min "cannot
tree-shake — nothing left to shake" while src/treeshake.test.ts proves the
opposite on every run. Corrected here and in FRAMEWORKS.md, with the measured
figures (1,837 vs 1,996 bytes for one decorator). The release facts the page
was hand-bumping — both tgz names, "0.4.0 lives in the repository", the
sixty-eight — now fill from meta.js/the bundle like the version badge always
has.

The workflows close three holes:

- The docs sync gate was blind to NEW untracked build outputs (git diff does
  not report them; demonstrated). Both workflows now run check:docs-sync, one
  shared script that fails on anything porcelain reports — which also ends
  the copy-paste divergence between them.
- pages.yml deploys on CI succeeding on main (workflow_run) instead of on the
  push itself, so a deploy implies green tests, not just in-sync docs. The
  deploy job refuses refs other than main, closing the workflow_dispatch
  any-branch deploy, and the build job drops pages/id-token — npm postinstall
  scripts no longer run alongside an OIDC grant.
- The Junie action is pinned to the commit behind v1.7.4 rather than the tag,
  which is the immutability the previous comment promised but a mutable ref
  cannot deliver.

Verified: every fix confirmed in a rendered browser in both themes; 72
contrast pairs still pass; no overflow at 20 widths; 268 tests, build,
check:types, check:docs, actionlint all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
Claude
2026-08-17 16:23:46 +00:00
parent f4c5e214f9
commit 4b910f19bf
8 changed files with 107 additions and 60 deletions
+2 -4
View File
@@ -47,7 +47,5 @@ jobs:
- name: Landing page loads nothing from the network - name: Landing page loads nothing from the network
run: npm run check:docs run: npm run check:docs
- name: Landing page bundle is in sync with src/ - name: Landing page bundle is in sync with src/
run: | # Also fails on NEW untracked files under docs/ — a plain `git diff` does not.
npm run build:docs run: npm run check:docs-sync
git diff --exit-code -- docs/ \
|| (echo "docs/ is stale — run 'npm run build:docs' and commit the result" && exit 1)
+5 -3
View File
@@ -39,9 +39,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Review the pull request - name: Review the pull request
# Pinned to the newest release rather than the moving v1 tag, so the version # Pinned to the commit behind release v1.7.4. A tag is a mutable ref the
# running is the version reviewed here. Bump deliberately. # publisher can repoint; only the SHA guarantees the version running is the
uses: JetBrains/junie-github-action@v1.7.4 # version that was reviewed — this workflow handles a repo secret. Bump by
# resolving the new release's commit, not by moving the tag name alone.
uses: JetBrains/junie-github-action@c2ae82fc9fbe0eb81942ceb3d9bd3f89a6b17b95 # v1.7.4
with: with:
junie_api_key: ${{ secrets.JUNIE_API_KEY }} junie_api_key: ${{ secrets.JUNIE_API_KEY }}
# Built-in structured review prompt, as opposed to a free-form instruction. # Built-in structured review prompt, as opposed to a free-form instruction.
+30 -23
View File
@@ -4,30 +4,28 @@ name: Deploy Pages
# directly, so the page is rebuilt from src/ and checked before it goes live instead # directly, so the page is rebuilt from src/ and checked before it goes live instead
# of after. # of after.
# #
# REQUIRES A ONE-TIME SETTING. Settings → Pages → Build and deployment → Source must # Triggered by CI completing on main rather than by the push itself, so a deploy
# be "GitHub Actions", not "Deploy from a branch". Until it is, the deploy job fails # implies the full suite passed — type-check, lint, tests, build, entry points, docs.
# with "Resource not accessible by integration" — the workflow is correct, the # A push that breaks a test turns main red and never reaches Pages; the previous
# repository is still configured to serve the branch. The switch cannot be made from # wiring deployed on any docs push, green CI or not. workflow_dispatch stays as the
# here: it needs a token with administration:write, which GITHUB_TOKEN is not. # manual escape hatch, and the deploy job refuses any ref that is not main.
# #
# It is reversible. Setting Source back to a branch restores the old behaviour and # REQUIRES A ONE-TIME SETTING. Settings → Pages → Build and deployment → Source must
# this workflow simply stops being able to deploy. # be "GitHub Actions", not "Deploy from a branch". Until it is, the first run fails —
# in the build job at configure-pages if Pages was never enabled, or in the deploy
# job with "Resource not accessible by integration" if Pages still serves a branch.
# Either way the workflow is correct; the repository setting is what needs to move.
# The switch cannot be made from here: it needs administration:write, which
# GITHUB_TOKEN is not. It is reversible — setting Source back to a branch restores
# the old behaviour and this workflow simply stops being able to deploy.
on: on:
push: workflow_run:
workflows: [ CI ]
types: [ completed ]
branches: [ main ] branches: [ main ]
# docs/ holds both the page and its generated bundle, so a src/ change only
# matters here once it has been rebuilt into docs/ — which is what CI enforces.
paths:
- 'docs/**'
- '.github/workflows/pages.yml'
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# Never cancel a deploy in flight: a half-published site is worse than a stale one. # Never cancel a deploy in flight: a half-published site is worse than a stale one.
# Queue instead, so the last push wins without interrupting the one already going out. # Queue instead, so the last push wins without interrupting the one already going out.
concurrency: concurrency:
@@ -38,6 +36,12 @@ jobs:
build: build:
name: Build and check name: Build and check
runs-on: ubuntu-latest runs-on: ubuntu-latest
# workflow_run fires on failure too — deploying is the one thing that must not.
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
# This job runs third-party code (npm postinstall scripts, the build toolchain),
# so it gets read-only. The Pages/OIDC grants live on the deploy job alone.
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -46,12 +50,10 @@ jobs:
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Rebuild the page bundle from src/
run: npm run build:docs
- name: The committed page is in sync with src/ - name: The committed page is in sync with src/
run: | # Rebuilds from src/ and fails on any difference, new untracked files included.
git diff --exit-code -- docs/ \ # Same script CI runs, so the two workflows cannot drift apart.
|| (echo "docs/ is stale — run 'npm run build:docs' and commit the result" && exit 1) run: npm run check:docs-sync
- name: The page loads nothing from the network - name: The page loads nothing from the network
run: npm run check:docs run: npm run check:docs
- uses: actions/configure-pages@v5 - uses: actions/configure-pages@v5
@@ -63,6 +65,11 @@ jobs:
name: Deploy name: Deploy
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
# workflow_dispatch can be pointed at any branch; production only ever serves main.
if: github.ref == 'refs/heads/main'
permissions:
pages: write
id-token: write
environment: environment:
name: github-pages name: github-pages
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}
+4 -3
View File
@@ -331,9 +331,10 @@ await esbuild.build({
`cereale/min` is the whole library flattened into one minified ES module (33.9 KB, 9.6 KB `cereale/min` is the whole library flattened into one minified ES module (33.9 KB, 9.6 KB
gzipped) for import maps, `<script type="module">`, Deno and Workers. gzipped) for import maps, `<script type="module">`, Deno and Workers.
**If you are using a bundler, do not use it.** It is the whole library in one file, so nothing **If you are using a bundler, prefer the default entry.** The flat file keeps its
can be dropped from it. The default entry point tree-shakes — one decorator costs about 1.8 KB `/*#__PURE__*/` annotations, so a bundler can still drop the rules you did not import — pinned
against 26 KB for everything — and produces a smaller result in any real application. See by `src/treeshake.test.ts` — but the per-module build shakes slightly leaner (1,837 bytes
against 1,996 for one decorator through esbuild) and is the canonical route. See
[Bundle size](README.md#bundle-size). [Bundle size](README.md#bundle-size).
```html ```html
+27 -26
View File
@@ -27,9 +27,10 @@
which already owns amber. Two amber families would make "our brand" and "needs which already owns amber. Two amber families would make "our brand" and "needs
your attention" the same colour. your attention" the same colour.
Every token below that differs between themes must be written in ALL FOUR places: Every token below that differs between themes must be written in THREE places: this
this block, the prefers-color-scheme block, [data-theme="light"] and block (the light palette), the prefers-color-scheme block, and [data-theme="dark"].
[data-theme="dark"]. Miss one and the toggle silently serves the light value. */ The dark media block is guarded with :not([data-theme="light"]), so an explicit light
toggle falls straight through to these values — there is no light copy to keep in sync. */
:root { :root {
--bg: #faf7f0; --bg: #faf7f0;
--bg-raised: #fffdf7; --bg-raised: #fffdf7;
@@ -95,7 +96,7 @@
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:not([data-theme="light"]) {
--bg: #12100b; --bg: #12100b;
--bg-raised: #1a1710; --bg-raised: #1a1710;
--bg-sunken: #16130d; --bg-sunken: #16130d;
@@ -120,16 +121,9 @@
} }
} }
/* The toggle wins over the media query in both directions. */ /* An explicit light choice: the guarded media block above no longer matches, so the
bare :root palette wins on its own. Only the UA hint needs stating. */
:root[data-theme="light"] { :root[data-theme="light"] {
--bg: #faf7f0; --bg-raised: #fffdf7; --bg-sunken: #f2ede1;
--text: #1a1712; --text-muted: #5c5346; --text-faint: #6e6353;
--border: #e6dfd1; --border-strong: #cfc5b2; --border-ui: #8e8269;
--accent: #8a6238; --accent-text: #7a5530; --accent-soft: #f1e9dc;
--accent-solid: #6b4a28; --on-accent: #fffdf7;
--bad: #a82820; --bad-soft: #faebe7; --ok: #256b3d; --ok-soft: #e8f2e9;
--warn: #8a5a05; --warn-soft: #fbf1dc;
--shadow: 0 1px 2px rgba(40, 30, 14, .05), 0 8px 24px -12px rgba(40, 30, 14, .20);
color-scheme: light; color-scheme: light;
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
@@ -173,7 +167,6 @@ p { margin: 0 0 1rem; }
--accent-text and --text-muted sit 1.14:1 apart — colour alone would lose them. */ --accent-text and --text-muted sit 1.14:1 apart — colour alone would lose them. */
a { color: var(--text); text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: .16em; } a { color: var(--text); text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: .16em; }
a:hover { color: var(--accent-text); text-decoration-color: currentColor; } a:hover { color: var(--accent-text); text-decoration-color: currentColor; }
.nav-links a, .foot-grid a, .brand { text-decoration: none; }
code, kbd, pre { font-family: var(--mono); } code, kbd, pre { font-family: var(--mono); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
@@ -200,6 +193,7 @@ section { padding-block: clamp(3rem, 6vw, 5.5rem); }
background: var(--accent-solid); color: var(--on-accent); padding: .6rem 1rem; border-radius: 0 0 var(--radius) 0; background: var(--accent-solid); color: var(--on-accent); padding: .6rem 1rem; border-radius: 0 0 var(--radius) 0;
} }
.skip:focus { left: 0; } .skip:focus { left: 0; }
.skip:hover { color: var(--on-accent); }
.sr-only { .sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
@@ -249,9 +243,12 @@ header.nav {
padding: .75rem 1.15rem; border-radius: var(--radius); text-decoration: none; cursor: pointer; border: 1px solid transparent; padding: .75rem 1.15rem; border-radius: var(--radius); text-decoration: none; cursor: pointer; border: 1px solid transparent;
} }
.btn-primary { background: var(--accent-solid); color: var(--on-accent); box-shadow: var(--shadow); } .btn-primary { background: var(--accent-solid); color: var(--on-accent); box-shadow: var(--shadow); }
.btn-primary:hover { filter: brightness(1.08); } /* Re-assert label colours on hover: the base a:hover (0,1,1) outranks these classes
(0,1,0), and in dark theme --accent-text equals --accent-solid — a hovered label
painted in its own background. */
.btn-primary:hover { filter: brightness(1.08); color: var(--on-accent); }
.btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-ui); } .btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-ui); }
.btn-secondary:hover { border-color: var(--text-faint); } .btn-secondary:hover { border-color: var(--text-faint); color: var(--text); }
.fact-row { .fact-row {
display: flex; flex-wrap: wrap; gap: .4rem .5rem; margin-top: 1.5rem; display: flex; flex-wrap: wrap; gap: .4rem .5rem; margin-top: 1.5rem;
font-size: .8125rem; color: var(--text-muted); font-size: .8125rem; color: var(--text-muted);
@@ -290,7 +287,7 @@ header.nav {
/* The page's one visual device: the line the compiler refuses. */ /* The page's one visual device: the line the compiler refuses. */
.ln--error { .ln--error {
background: rgba(255, 106, 126, .09); background: color-mix(in srgb, var(--err-line) 9%, transparent);
text-decoration: underline wavy var(--err-line); text-decoration: underline wavy var(--err-line);
text-decoration-skip-ink: none; text-decoration-skip-ink: none;
text-underline-offset: .32em; text-underline-offset: .32em;
@@ -298,7 +295,7 @@ header.nav {
.tsc-error { .tsc-error {
display: flex; gap: .6rem; align-items: flex-start; display: flex; gap: .6rem; align-items: flex-start;
margin: 0; padding: .7rem .9rem; border-top: 1px solid var(--code-border); margin: 0; padding: .7rem .9rem; border-top: 1px solid var(--code-border);
background: rgba(255, 106, 126, .08); color: var(--err-text); background: color-mix(in srgb, var(--err-line) 8%, transparent); color: var(--err-text);
font: 500 .78125rem/1.5 var(--mono); font: 500 .78125rem/1.5 var(--mono);
} }
.tsc-error .mark { color: var(--err-line); flex-shrink: 0; } .tsc-error .mark { color: var(--err-line); flex-shrink: 0; }
@@ -348,13 +345,16 @@ header.nav {
overflow: auto; overflow: auto;
} }
#editor:focus { outline: 2px solid var(--accent-on-code); outline-offset: -2px; } #editor:focus { outline: 2px solid var(--accent-on-code); outline-offset: -2px; }
/* Scrollable code samples are keyboard-focusable in Chromium; the page-level ring is
unreadable on the dark slab and its +2px offset would be clipped by .code overflow. */
.code :focus-visible, #output:focus-visible { outline: 2px solid var(--accent-on-code); outline-offset: -2px; }
#output { #output {
flex: 1; min-height: 400px; margin: 0; overflow: auto; flex: 1; min-height: 400px; margin: 0; overflow: auto;
background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border); background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border);
border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg); border-top: none; border-radius: 0 0 var(--radius-lg) var(--radius-lg);
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); white-space: pre-wrap; word-break: break-word; padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); white-space: pre-wrap; word-break: break-word;
} }
#output .out-err { color: #ff8095; } #output .out-err { color: var(--err-text); }
#output .out-dim { color: var(--code-faint); } #output .out-dim { color: var(--code-faint); }
.pg-note { font-size: .8125rem; color: var(--text-muted); margin: .9rem 0 0; } .pg-note { font-size: .8125rem; color: var(--text-muted); margin: .9rem 0 0; }
@@ -370,7 +370,7 @@ td.note { white-space: normal; color: var(--text-muted); font-size: .875rem; }
.ref-bar { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin-bottom: 1.5rem; } .ref-bar { display: flex; flex-wrap: wrap; gap: .75rem; align-items: center; margin-bottom: 1.5rem; }
#ref-filter { #ref-filter {
flex: 1; min-width: 210px; font: .9375rem var(--sans); padding: .6rem .85rem; flex: 1; min-width: 210px; font: .9375rem var(--sans); padding: .6rem .85rem;
border: 1px solid var(--border-strong); border-radius: var(--radius); border: 1px solid var(--border-ui); border-radius: var(--radius);
background: var(--bg-raised); color: var(--text); background: var(--bg-raised); color: var(--text);
} }
#ref-count { font-size: .875rem; color: var(--text-muted); font-variant-numeric: tabular-nums; } #ref-count { font-size: .875rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
@@ -817,7 +817,7 @@ export default defineConfig({
<p class="compare-label"><span class="pill pill-bad">not on npm yet</span> installing it today</p> <p class="compare-label"><span class="pill pill-bad">not on npm yet</span> installing it today</p>
<div class="code"> <div class="code">
<div class="code-head"><span class="name">shell</span></div> <div class="code-head"><span class="name">shell</span></div>
<pre><code data-lang="text">git clone https://github.com/avalon-vanguard/cereale <pre><code data-lang="text" id="install-shell">git clone https://github.com/avalon-vanguard/cereale
cd cereale cd cereale
npm install &amp;&amp; npm run build npm install &amp;&amp; npm run build
npm pack # → cereale-0.4.0.tgz npm pack # → cereale-0.4.0.tgz
@@ -851,9 +851,10 @@ npm install ../cereale/cereale-0.4.0.tgz</code></pre>
&lt;/script&gt;</code></pre> &lt;/script&gt;</code></pre>
</div> </div>
<p class="pg-note"> <p class="pg-note">
<strong>If you are using a bundler, do not use it.</strong> It is the whole library in one <strong>If you are using a bundler, prefer the default entry.</strong> The flat file keeps
file, so nothing can be dropped from it. The default entry point tree-shakes; this one its purity annotations, so a bundler can still drop the rules you did not import — that
cannot, because there is nothing left to shake. is pinned by a test — but the per-module build shakes slightly leaner (1,837 bytes
against 1,996 for one decorator) and is the canonical route.
</p> </p>
</div> </div>
</div> </div>
@@ -866,7 +867,7 @@ npm install ../cereale/cereale-0.4.0.tgz</code></pre>
<p class="eyebrow">Bundle size</p> <p class="eyebrow">Bundle size</p>
<h2>You pay for the decorators you name</h2> <h2>You pay for the decorators you name</h2>
<p class="lede"> <p class="lede">
Sixty-eight decorators is a lot to ship to a browser, so none of the ones you did not <b class="js-dec-count">68</b> decorators is a lot to ship to a browser, so none of the ones you did not
import are shipped. Minified bytes, measured through all three bundlers. import are shipped. Minified bytes, measured through all three bundlers.
</p> </p>
</div> </div>
@@ -998,7 +999,7 @@ npm install ../cereale/cereale-0.4.0.tgz</code></pre>
</div> </div>
<div class="note-item"> <div class="note-item">
<h3>It is not on npm yet</h3> <h3>It is not on npm yet</h3>
<p>0.4.0 lives in the repository. <code class="inline-code">npm install cereale</code> does not <p><span class="js-version">0.4.0</span> lives in the repository. <code class="inline-code">npm install cereale</code> does not
resolve to this library — build it from source until it is published.</p> resolve to this library — build it from source until it is published.</p>
</div> </div>
</div> </div>
+16
View File
@@ -39,6 +39,22 @@
if (versionEl && meta.version) versionEl.textContent = 'v' + meta.version; if (versionEl && meta.version) versionEl.textContent = 'v' + meta.version;
var nodeEl = document.getElementById('node-req'); var nodeEl = document.getElementById('node-req');
if (nodeEl && meta.node) nodeEl.textContent = meta.node.replace('>=', '≥').replace('.0.0', ''); if (nodeEl && meta.node) nodeEl.textContent = meta.node.replace('>=', '≥').replace('.0.0', '');
if (meta.version) {
Array.prototype.forEach.call(document.querySelectorAll('.js-version'), function (el) {
el.textContent = meta.version;
});
// The install snippet names the tarball npm pack produces; keep it tied to the
// same package.json fact the badge uses instead of hand-bumping it each release.
var shell = document.getElementById('install-shell');
if (shell) {
shell.textContent = shell.textContent.replace(/cereale-[\d.]+\.tgz/g, 'cereale-' + meta.version + '.tgz');
}
}
if (decoratorCount) {
Array.prototype.forEach.call(document.querySelectorAll('.js-dec-count'), function (el) {
el.textContent = String(decoratorCount);
});
}
/* ------------------------------------------------------- highlighting */ /* ------------------------------------------------------- highlighting */
var TOKENS = [ var TOKENS = [
+2 -1
View File
@@ -50,7 +50,8 @@
"verify": "npm run type-check && npm run lint && npm run test && npm run build && npm run check:types && npm run check:docs", "verify": "npm run type-check && npm run lint && npm run test && npm run build && npm run check:types && npm run check:docs",
"prepublishOnly": "npm run verify", "prepublishOnly": "npm run verify",
"check:docs": "node scripts/check-docs.mjs", "check:docs": "node scripts/check-docs.mjs",
"check:types": "node scripts/check-types.mjs" "check:types": "node scripts/check-types.mjs",
"check:docs-sync": "npm run build:docs && node scripts/check-docs-sync.mjs"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
+21
View File
@@ -0,0 +1,21 @@
/**
* Fails if docs/ differs from what `npm run build:docs` just produced — including NEW
* files. That last part is the reason this exists: `git diff --exit-code -- docs/` only
* reports modifications to tracked files, so a build-script change whose only effect is
* an additional output (a sourcemap, a second vendor asset) passed the old gate silently
* and would have been deployed without ever being committed or reviewed.
*
* Run after build:docs (the check:docs-sync npm script chains them). Shared by ci.yml
* and pages.yml so the two workflows cannot drift into enforcing different notions of
* "in sync" — they already had, before this was extracted.
*/
import { execFileSync } from 'node:child_process';
const out = execFileSync('git', ['status', '--porcelain', '--', 'docs/'], { encoding: 'utf8' }).trim();
if (out) {
console.error(out);
console.error("docs/ is stale — run 'npm run build:docs' and commit the result");
process.exit(1);
}
console.log('docs/ matches src/ — nothing modified, nothing untracked.');