The header says when it drops an action instead of dropping it in silence

studio-impl found this by building against it: the shell renders at most six app
actions and `decl.actions.slice(0, 6)` discarded the rest without a word. A surface
declaring seven rendered six and had no way to discover it — not in a DOM it cannot
see, not in any response, not anywhere. The seventh button simply never worked,
which reads as a bug in the app rather than a limit of the shell.

The bound itself is right and stays: the left half shares one fixed bar with the
title, the crumb and the shell's own half, so an unlimited list pushes into
controls the surface does not own. The silence was the defect.

Told on BOTH channels, because they reach different people. A console warning
naming the dropped ids reaches whoever is building the app right now. A
`{t:"header-overflow", limit, declared, dropped}` message reaches THE APP, which is
the only party that can adapt — and "an app cannot see the cap to respect it" was
the actual complaint, which a doc alone would not have answered.

The number is now a named constant rather than a literal inside a slice. That is
why it went undocumented: it existed in exactly one place with no name to look up
and nothing for a doc to point at. A committed test reads the shipped limit and the
documented one and compares them, so the two cannot drift.

WHILE DOCUMENTING IT I FALSIFIED A NEIGHBOURING SENTENCE AND FIXED IT:
shell-protocol.md said `markup` "is the one message that travels in that
direction". Adding a second made that false in the same commit that made it false.

Verified in a live shell rather than reasoned about — `tellSurface` is a const
declared after renderAppHeader's first caller, and this file has shipped four
TDZs. A surface declaring eight actions rendered six, received
{limit:6, declared:8, dropped:["a7","a8"]}, and the console carried the warning
naming them. The committed checks are structural and say so, each with a control.

290 tests.
This commit is contained in:
mvschwarz committed 2026-08-05 06:43:53 -07:00
1 parent f6f3c66d5d
commit 9c824a7b06
3 files changed
+142 -3

No files matched your search

+23 -1
View File
@@ -586,11 +586,33 @@
// running and still holds a live channel, so without this check the header shows
// whichever surface spoke last rather than the one you are looking at.
const appActionsEl = document.getElementById("appActions");
// HOW MANY ACTIONS FIT. The left half shares one fixed bar with the title, the
// crumb and the shell's own half, so this is bounded rather than unbounded — an
// unlimited list pushes into controls the surface does not own.
//
// THE BOUND IS FINE; THE SILENCE WAS THE DEFECT. This used to slice and say
// nothing, so a surface declaring seven actions rendered six and had no way to
// find out: not in the DOM it cannot see, not in any response, not anywhere. The
// seventh button simply never worked, which reads as a bug in the app.
const HEADER_ACTION_LIMIT = 6;
function renderAppHeader(decl) {
if (decl.title !== undefined) document.getElementById("topTitle").textContent = decl.title;
if (decl.crumb !== undefined) document.getElementById("topCrumb").textContent = decl.crumb;
if (!Array.isArray(decl.actions)) return;
appActionsEl.replaceChildren(...decl.actions.slice(0, 6).map((a) => {
// Told twice, because the two channels reach different people: the console
// reaches whoever is building the app right now, and the message reaches the
// APP, which is the only one that can actually adapt. Answering "an app cannot
// see the cap to respect it" needs the second one.
if (decl.actions.length > HEADER_ACTION_LIMIT) {
const dropped = decl.actions.slice(HEADER_ACTION_LIMIT).map((a) => a?.id ?? a?.label ?? "(unnamed)");
console.warn(
`studio shell: the header shows at most ${HEADER_ACTION_LIMIT} app actions; ` +
`${dropped.length} were dropped: ${dropped.join(", ")}. ` +
`Declare fewer, or move the rest into the surface.`);
tellSurface({ t: "header-overflow", limit: HEADER_ACTION_LIMIT,
declared: decl.actions.length, dropped });
}
appActionsEl.replaceChildren(...decl.actions.slice(0, HEADER_ACTION_LIMIT).map((a) => {
const btn = document.createElement("button");
btn.textContent = String(a.label ?? a.id ?? "");
btn.title = String(a.title ?? "");
+27 -2
View File
@@ -137,6 +137,31 @@ Clicking a declared action posts **`{ "t": "header-action", "id": "<id>" }`** ba
to the surface. The shell does not know what any action means and does not try:
it renders a label and returns the click.
### The header shows at most SIX actions, and says when it drops some (stable)
The left half shares one fixed bar with the title, the crumb and the shell's own
half, so the list is bounded rather than unbounded — an unlimited one pushes into
controls the surface does not own.
**The bound is not the interesting part; being told about it is.** This used to
truncate in silence, so a surface declaring seven actions rendered six and had no
way to discover it — not in a DOM it cannot see, not in any response. The seventh
button simply never worked, which reads as a bug in the app.
Declaring more than six now produces a console warning naming the dropped ids
**and** a message back to the surface:
```json
{ "t": "header-overflow", "limit": 6, "declared": 8, "dropped": ["export", "share"] }
```
Two channels because they reach different people: the console reaches whoever is
building the app, and the message reaches **the app**, which is the only party that
can actually adapt — collapse the extras into a menu, or move them into the
surface body.
A surface that ignores the message is unaffected beyond the drop itself.
**Only the ACTIVE surface may write the header.** A background surface is still
running and still holds a live channel, so without that rule the header shows
whichever surface spoke last rather than the one you are looking at. Actions are
@@ -160,8 +185,8 @@ for one mode are two things that can disagree, and the one a surface did not wri
is the one that will be wrong.**
**`{ "t": "markup", "on": <boolean> }`** is sent from the shell **to** the active
surface when markup mode changes. It is the one message that travels in that
direction, and it is **advisory**: a surface that ignores it is unaffected and
surface when markup mode changes. It is **advisory**: a surface that ignores it is
unaffected and
still annotatable, because annotation is drawn by the shell over the stage rather
than by the surface. A surface handles it only when it wants to participate — to
render its own richer annotations, or to get its own controls out of the way.
+92
View File
@@ -0,0 +1,92 @@
// The header action cap is bounded, named, and NOT silent.
//
// node --test 'test/*.test.mjs'
//
// STRUCTURAL by necessity — this repo has no browser dependency. The behaviour was
// driven in a live shell: a surface declaring eight actions rendered six, received
// {t:"header-overflow", limit:6, declared:8, dropped:["a7","a8"]}, and the console
// carried a warning naming them. What is committed here is that the cap stays
// NAMED rather than a bare literal, and that neither disclosure channel can be
// removed without a test failing.
//
// Found by studio-impl while building against it, which is the only way a silent
// cap ever gets found: the seventh button does not work and nothing says why.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const shell = () => fs.readFileSync(path.join(REPO, "app", "shell.html"), "utf8");
const doc = () => fs.readFileSync(path.join(REPO, "contract", "shell-protocol.md"), "utf8");
const renderFn = (src) => {
const m = src.match(/function renderAppHeader\(decl\) \{([\s\S]*?)\n \}/);
assert.ok(m, "shell.html no longer defines renderAppHeader");
return m[1];
};
test("the cap is a NAMED constant, not a literal buried in a slice", () => {
// A bare `slice(0, 6)` is how this stayed undocumented: the number existed in
// exactly one place, with no name to look up and nothing to point a doc at.
const src = shell();
assert.match(src, /const HEADER_ACTION_LIMIT = \d+;/,
"the header action cap is not a named constant");
assert.doesNotMatch(renderFn(src), /slice\(0,\s*\d+\)/,
"renderAppHeader still slices on a bare literal — the cap has two homes again");
});
test("dropping actions is disclosed on BOTH channels", () => {
const body = renderFn(shell());
assert.match(body, /console\.warn/,
"no console warning: whoever is building the app is told nothing");
assert.match(body, /header-overflow/,
"nothing is posted back: the APP cannot see the cap, which was the whole finding");
assert.match(body, /dropped/,
"the disclosure does not name which actions were dropped");
});
test("the documented limit is the limit the shell enforces", () => {
// Two places stating one number is the drift shape this repo has paid for three
// times. Read BOTH and compare, rather than trusting either.
const shipped = shell().match(/const HEADER_ACTION_LIMIT = (\d+);/);
assert.ok(shipped, "cannot read the shipped limit");
const documented = doc().match(/header shows at most \*?\*?(\w+)/i);
assert.ok(documented, "shell-protocol.md no longer states the header action limit");
const words = { six: 6, seven: 7, five: 5, eight: 8, four: 4 };
const stated = words[documented[1].toLowerCase()] ?? Number(documented[1]);
assert.equal(stated, Number(shipped[1]),
`the contract says ${documented[1]} header actions and the shell enforces ${shipped[1]}`);
});
test("the overflow message shape in the doc matches what the shell sends", () => {
const body = renderFn(shell());
const d = doc();
for (const field of ["limit", "declared", "dropped"]) {
assert.match(body, new RegExp(field), `the shell does not send \`${field}\``);
assert.match(d, new RegExp(`"${field}"`), `shell-protocol.md does not document \`${field}\``);
}
});
test("positive control: these checks can fail", () => {
// Each assertion above is a regex over source, the shape that silently matches
// nothing. Planted against mutated copies rather than trusted.
const src = shell();
const noWarn = src.replace(/console\.warn\(/g, "void(");
assert.doesNotMatch(renderFn(noWarn), /console\.warn/,
"the console warning survived removal, so that assertion proves nothing");
const noPost = src.replace(/header-overflow/g, "header-nothing");
assert.doesNotMatch(renderFn(noPost), /header-overflow/,
"the overflow message survived removal, so that assertion proves nothing");
const relit = src.replace(/const HEADER_ACTION_LIMIT = \d+;/, "")
.replace(/slice\(0, HEADER_ACTION_LIMIT\)/, "slice(0, 6)");
assert.doesNotMatch(relit, /const HEADER_ACTION_LIMIT = \d+;/,
"the named constant survived removal");
assert.match(renderFn(relit), /slice\(0,\s*6\)/,
"the bare-literal regression could not be planted, so the doesNotMatch above is untested");
});