Landing picks an environment; each environment is a tabbed page opening on Play
ci / web (push) Successful in 3m10s
ci / python (push) Successful in 2m32s

The environment page was a linear scroll of eight narrative beats. That is an
essay, and it is the wrong shape for somebody who has just chosen an
environment and wants to use it. It is now four tabs — Play, Watch, Reward,
Evidence — opening on Play, with the board above the fold at 390x844 and the
anatomy strip directly beneath it. The landing page leads with the picker
instead of burying it under the thesis.

The contract changed rather than layering tabs over beats. `Narrative.beats` is
gone; `claims: Record<DemoTabId, string>` replaces it, one required sentence per
tab. Writing the claim is how an author discovers whether a tab has anything to
say — a tab whose claim is hard to write is usually a tab with nothing in it.
Doing this now costs one migration; doing it after eleven more environments
costs twelve.

Tabs are derived, never declared: Play iff the demo ships an `interactive` mode,
Watch iff it has recorded runs. A demo that could name its own tabs would mean
environment seven inventing a fifth one and the site ceasing to be one product.

One thing the browser caught that no gate would have. The header stat strip
describes the RECORDED RUN, and on Play it sat above the visitor's own empty
board reading "Outcome: failed" — which parses as your game having already
failed before you touch a key. It now renders only on the tabs whose subject is
that run, which also moved the board 54px up the page.

The picker is honest about the shape of the lineup by construction: one built
environment gets its own block and the demo's real board as its thumbnail,
twelve written specifications render dimmed with a Spec badge, and every count
on the page is derived from the data rather than typed.

206 contract checks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
karti-ai
2026-08-28 18:07:34 -07:00
parent f5f45df224
commit 5bbf913664
23 changed files with 2005 additions and 866 deletions
+320 -3
View File
@@ -6,10 +6,11 @@
* shapes; this enforces everything a type cannot: that the directory name
* matches the slug, that a `spec` demo is a real published specification rather
* than a coming-soon card, that a `live` demo's traces exist on disk, that the
* shared shell has no idea any particular demo exists, and that the reward has
* something pulling against its objective.
* shared shell has no idea any particular demo exists, that every tab on the
* page asserts something a reader can check, and that the reward has something
* pulling against its objective.
*
* Thirteen numbered rules, each reported with the file to open. Run it with
* Fourteen numbered rules, each reported with the file to open. Run it with
* `pnpm check`.
*
* Most rules are checked by READING the TypeScript, not by running it — the
@@ -34,12 +35,15 @@ import {
die,
exists,
findInDemo,
evalLiteral,
isUnresolved,
literalAfter,
loadManifest,
loadMeta,
parseStringUnion,
read,
rel,
segment,
traceFile,
walk,
} from './_lib.mjs';
@@ -56,9 +60,50 @@ const typesSrc = read(TYPES_FILE);
// would drift, and it would drift silently in the direction of passing.
const VERTICALS = parseStringUnion(typesSrc, 'Vertical');
const STATUSES = parseStringUnion(typesSrc, 'DemoStatus');
const TAB_IDS = parseStringUnion(typesSrc, 'DemoTabId');
const ROLES = ['objective', 'counterweight', 'gate'];
if (!VERTICALS) die(`could not parse the \`Vertical\` union out of ${rel(TYPES_FILE)}.`);
if (!STATUSES) die(`could not parse the \`DemoStatus\` union out of ${rel(TYPES_FILE)}.`);
if (!TAB_IDS) {
die(
`could not parse the \`DemoTabId\` union out of ${rel(TYPES_FILE)}. ` +
'Rule 3 requires one claim per tab, and the list of tabs lives there.',
);
}
/**
* `DEMO_TABS` is the runtime half of `DemoTabId`, and the shell orders the tab
* bar from it. If the two ever disagree, rule 3 would happily pass a demo that
* is missing a claim for a tab the page actually renders — so they are compared
* here, once, before any demo is looked at.
*/
(() => {
let text = null;
try {
text = literalAfter(typesSrc, /(?:export\s+)?const\s+DEMO_TABS\s*(?::\s*[^=]+)?=\s*/, '[');
} catch {
text = null;
}
if (text === null) {
report.fail(
rel(TYPES_FILE),
'rule 3 (one claim per tab)',
'no `DEMO_TABS` array literal found. The union says what a tab id is; the array says what order the ' +
'tabs come in, and the shell reads the array. Both have to exist.',
);
return;
}
const parsed = evalLiteral(text, 'DEMO_TABS');
const listed = Array.isArray(parsed.value) ? parsed.value : null;
report.check(
listed !== null && listed.length === TAB_IDS.length && listed.every((id, i) => id === TAB_IDS[i]),
rel(TYPES_FILE),
'rule 3 (one claim per tab)',
`DEMO_TABS is ${JSON.stringify(listed)} but the DemoTabId union is ${JSON.stringify(TAB_IDS)}. ` +
'They are the same list written twice; a demo cannot be checked against a contract that disagrees ' +
'with itself.',
);
})();
/**
* Every icon name lucide-react actually exports, from its own type
@@ -97,12 +142,73 @@ const META_FIELDS = {
/* --------------------------------------------------------- literal anchors */
const COMPONENT_ANCHORS = [/\bcomponents\s*:\s*/];
const NARRATIVE_ANCHORS = [
/(?:export\s+)?const\s+narrative\s*(?::\s*[^=]+)?=\s*/,
/(?:export\s+)?const\s+[A-Za-z_$][\w$]*Narrative\s*(?::\s*[^=]+)?=\s*/,
/\bnarrative\s*:\s*/,
];
const ANATOMY_ANCHORS = [/(?:export\s+)?const\s+anatomy\s*(?::\s*[^=]+)?=\s*/, /\banatomy\s*:\s*/];
const PROVENANCE_ANCHORS = [
/(?:export\s+)?const\s+provenance\s*(?::\s*[^=]+)?=\s*/,
/\bprovenance\s*:\s*/,
];
/* --------------------------------------------------------- rule 3 helpers */
/**
* The shortest thing that can still be a claim rather than a heading.
*
* Calibrated against the shortest real one on the site — "Move one slider and
* the winner changes." at 42 characters — with a wide margin, because the point
* is to catch `play: 'Play'`, not to police brevity.
*/
const MIN_CLAIM_CHARS = 20;
const MIN_CLAIM_WORDS = 4;
/** Lower-cased, punctuation stripped, whitespace collapsed. */
const normalise = (text) =>
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
/**
* Why `claims[tab]` is not a claim, or null if it is one.
*
* A claim is a sentence the tab then has to demonstrate. The three ways one
* arrives broken are: absent, a heading pasted in as prose, and the tab's own
* name — all of which type-check, and all of which render as a page that
* asserts nothing.
*/
function claimProblem(tab, value) {
const label = `narrative.claims.${tab}`;
if (isUnresolved(value)) {
return `${label} is the imported identifier \`${Object.values(value)[0] ?? '?'}\`, not a written-out ` +
'string. The claims are read on the page beside the surface they describe, so they are written here.';
}
if (typeof value !== 'string' || value.trim().length === 0) {
return `${label} is ${value === undefined ? 'missing' : JSON.stringify(value)}. Every tab needs one, ` +
'including a tab this demo may not render — writing the claim is how you find out whether the tab has ' +
'anything in it.';
}
const text = value.trim();
const norm = normalise(text);
// Stripped of the articles and the word "tab", is it just the tab's name?
const bare = norm.replace(/^(?:the|a|an)\s+/, '').replace(/\s+tab$/, '');
if (bare === tab) {
return `${label} is ${JSON.stringify(text)}, which is the tab's own name. The tab bar already says ` +
'that. This is the sentence the tab has to earn.';
}
if (text.length < MIN_CLAIM_CHARS) {
return `${label} is ${text.length} characters (${JSON.stringify(text)}). Under ${MIN_CLAIM_CHARS} it is a ` +
'heading, not a claim — say what the reader will be able to see for themselves on this tab.';
}
if (norm.split(' ').filter(Boolean).length < MIN_CLAIM_WORDS) {
return `${label} is ${JSON.stringify(text)}. A claim is a sentence; ${MIN_CLAIM_WORDS} words is the floor.`;
}
return null;
}
/* ------------------------------------------------------------- the checks */
const slugs = demoSlugs();
@@ -142,6 +248,9 @@ for (const slug of slugs) {
report.staticOnly(`${slug}: lucide-react is not installed, so \`icon\` was only checked for PascalCase shape.`);
}
/* -- 3. the narrative: a thesis, an anxiety, four claims, a limit ------- */
checkNarrative(slug, report);
/* -- 4. the social card exists ------------------------------------------ */
if (nonEmptyString(meta.ogImage)) {
const card = abs('public', String(meta.ogImage).replace(/^\/+/, ''));
@@ -347,6 +456,9 @@ for (const slug of slugs) {
/* -- 13. every step announces itself ------------------------------------ */
checkAnnounce(slug, report, manifest);
/* -- 14. the page still renders without the interactive half ------------ */
checkEagerHalfStandsAlone(slug, report, metaFile);
}
/* ---------------------------------------- 8. the shell knows no demo names */
@@ -412,6 +524,211 @@ report.finish();
/* ------------------------------------------------------------- helpers */
/**
* Rule 3: the narrative asserts something, tab by tab.
*
* The page is four tabs, not an essay, so the narrative is no longer a list of
* beats with their own surfaces — the shell decides which tabs exist and in
* what order. What the demo owes is the one sentence each tab has to earn, and
* a claim is the field most likely to be left as a heading, because a heading
* type-checks.
*/
function checkNarrative(slug, report) {
const rule = 'rule 3 (one claim per tab)';
const dir = path.join(DEMOS_DIR, slug);
// Lenient, because a narrative may legitimately interpolate a shared
// constant; `claimProblem` then reports the stand-in by name rather than
// letting the whole literal fail to evaluate.
const found = findInDemo(slug, NARRATIVE_ANCHORS, '{', 'narrative', { lenient: true });
if (found.error) {
report.fail(found.file ?? rel(dir), rule, found.error);
return;
}
const narrative = found.value;
const file = found.file;
if (narrative === null || typeof narrative !== 'object') {
report.fail(file, rule, 'the narrative is not an object literal.');
return;
}
report.check(
nonEmptyString(narrative.thesis),
file,
rule,
'narrative.thesis is empty. It is the paragraph that says why this environment is worth the reader\'s ' +
'next five minutes, and no tab supplies it.',
);
report.check(
nonEmptyString(narrative.anxiety),
file,
rule,
'narrative.anxiety is empty. It is the question already in the reader\'s head when they land; the page ' +
'is built to answer it and cannot if it is not written down.',
);
/* The claims. */
if (Array.isArray(narrative.beats) || 'beats' in narrative) {
report.fail(
file,
rule,
'narrative still has `beats`. The page is four tabs now, and which tabs exist is the shell\'s decision, ' +
'not the demo\'s. Replace `beats` with `claims`: one sentence per tab id.',
);
}
const claims = narrative.claims;
if (claims === null || typeof claims !== 'object' || Array.isArray(claims)) {
report.fail(
file,
rule,
`narrative.claims is ${claims === undefined ? 'missing' : JSON.stringify(claims)}. It must be an object ` +
`keyed by tab id: ${TAB_IDS.join(', ')}.`,
);
return;
}
for (const tab of TAB_IDS) {
const problem = claimProblem(tab, claims[tab]);
report.check(problem === null, file, rule, problem ?? '');
}
for (const key of Object.keys(claims)) {
report.check(
TAB_IDS.includes(key),
file,
rule,
`narrative.claims has a key "${key}", which is not a tab. The tabs are ${TAB_IDS.join(', ')}, and they ` +
'are fixed by the contract — a demo that needs a fifth surface adds it through `tabs`, not here.',
);
}
/* The limits. */
const limits = narrative.limits;
if (!Array.isArray(limits) || limits.length === 0) {
report.fail(
file,
rule,
'narrative.limits is empty. Every environment teaches something narrower than the thing it is standing ' +
'in for, and the page that will not say what is the one a reader is right to distrust.',
);
return;
}
limits.forEach((limit, i) => {
report.check(
nonEmptyString(limit?.text),
file,
rule,
`narrative.limits[${i}].text is empty.`,
);
});
}
/**
* Rule 14: the eager half of a demo does not depend on the interactive half.
*
* Play is a tab now, and it is the tab a demo may not have: a demo with no
* `interactive` opens on Watch, and one with no runs has no Watch tab at all.
* That only works if `meta`, `narrative` and `reward` can be rendered on their
* own. `meta.ts` is where it goes wrong first, because it is loaded EAGERLY for
* every demo on every page — an import of `./demo` there drags one demo's board,
* its controls and its word list into the entry chunk for all of them, and does
* it silently.
*
* Only imports that stay inside the demo's own directory are followed. Reaching
* out of it is rule 9's business, and following `@/lib/demo-kit` would walk into
* the shell's own React and report a failure that belongs to nobody.
*/
function checkEagerHalfStandsAlone(slug, report, metaFile) {
const rule = 'rule 14 (the eager half stands alone)';
const dir = path.join(DEMOS_DIR, slug);
const entry = abs(metaFile);
if (!exists(entry)) return;
// Where `interactive` is declared. In practice demo.tsx, but the contract is
// about the value, not the filename.
const interactiveFiles = demoFiles(slug).filter((f) => /\binteractive\s*:/.test(codeOnly(read(f))));
const closure = localClosure(entry, dir);
for (const file of closure) {
if (interactiveFiles.includes(file)) {
report.fail(
rel(entry),
rule,
`reaches ${rel(file)}, which declares \`interactive\`. meta must be renderable with the interactive ` +
'mode absent — it is what the gallery card, the header and the router are built from, and Play is ' +
'the one tab a demo is allowed not to have. Move whatever meta needs into a plain data module.',
);
} else if (/\.tsx$/.test(file)) {
report.fail(
rel(entry),
rule,
`imports ${rel(file)}, a component module. meta is eagerly loaded for EVERY demo on every page, so it ` +
'stays plain serialisable data: no React, no board, no controls. That is also what keeps Play ' +
'optional rather than load-bearing.',
);
} else {
report.passed += 1;
}
}
if (closure.length === 0) report.passed += 1;
// `interactive` present but nothing to play with is the other half of the
// same contract: the shell would render a Play tab over an empty board.
for (const file of interactiveFiles) {
const code = codeOnly(read(file));
report.check(
/\binit\s*:/.test(code) && /\bControls\s*:/.test(code),
rel(file),
rule,
'declares `interactive` without both `init` and `Controls`. The shell shows the Play tab because ' +
'`interactive` exists; a half-declared one is a default tab with nothing in it.',
);
}
}
/** Source with comments and string literals blanked out. */
function codeOnly(src) {
return segment(src)
.map((span) => (span.code ? span.text : ' '))
.join('');
}
/**
* Every file inside `dir` that `entry` imports, transitively. Excludes `entry`.
*/
function localClosure(entry, dir) {
const seen = new Set([entry]);
const queue = [entry];
while (queue.length > 0) {
const file = queue.pop();
for (const spec of importSpecifiers(read(file))) {
const resolved = resolveWithin(spec, file, dir);
if (resolved && !seen.has(resolved)) {
seen.add(resolved);
queue.push(resolved);
}
}
}
seen.delete(entry);
return [...seen].sort();
}
/** Resolve a specifier to a file under `dir`, or null if it leaves it. */
function resolveWithin(spec, fromFile, dir) {
const bare = String(spec).split('?')[0];
let base;
if (bare.startsWith('.')) base = path.resolve(path.dirname(fromFile), bare);
else if (bare.startsWith('@/')) base = abs('src', bare.slice(2));
else return null;
if (path.relative(dir, base).startsWith('..')) return null;
for (const ext of ['', '.ts', '.tsx', '/index.ts', '/index.tsx']) {
const candidate = base + ext;
if (exists(candidate) && fs.statSync(candidate).isFile()) return candidate;
}
return null;
}
/** Every module specifier a file imports, static, dynamic or side-effect. */
function importSpecifiers(src) {
const out = new Set();