fix: the app bar is reserved, not fitted, and a message is a toast (improvise-n6t8)
The rule is the deliverable: nothing a user expects to be fixed may move.
Four children of <header> were sized by their own content and all four
changed on the SAME event, so the bar reflowed on a mode change with no
message at all: #mode (mode_label, 4-14 chars), #hint (hint_line, one
line per mode), #doc-name (gains and loses " [+]") and #view-name, which
had :empty { display: none } and so appeared out of nothing.
Every child that changes with a keystroke now has an allocated width, and
#hint is the single elastic child, so nothing is positioned by it. The
mode pill's reserve is a FLOOR and deliberately not a cap: a cap absorbs
an over-long label inside a box that never grows, which is a reserve that
hides its own failure. AppMode's exhaustive-match methods make the
compiler demand mode_label for a new variant; nothing checked the CSS
still fit it. harness/tests/header.spec.js is that check.
The spec found what the ticket did not predict: the compounding one was
not the pill. #status was flex: 0 1 auto beside a flex: 1 1 auto hint, so
a longer hint took the shrink out of #status, which WRAPPED to two and
three lines and grew the header by a row -- 52.5px to 71.0px. Reserving
the pill alone would have left that untouched.
ViewState::status_msg is now a toast in a position: fixed dock anchored at
its bottom, so it takes part in no layout and stacks ABOVE the minibuffer:
the prompt is neither covered nor displaced. composeStatus is gone -- the
two facts go to two elements, so a repaint reaches onModel and cannot
address onShell at all. That is gotcha 46's rule made structural rather
than kept by a join's convention; its unit test was replaced, not deleted.
Dismissal keys off message IDENTITY, not a timer. status_msg is STATE and
repaint() re-offers it on every revision, so a setTimeout alone resurrects
the toast on the next keypress. Cost stated rather than hidden: two
identical messages in a row show once, because nothing carries a sequence
and the alternative reading of an ambiguous offer is exactly the bug.
Nine of seventeen modes are reachable in a browser and the spec names them
individually; three injected faults are recorded in the evidence file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
+52
-13
@@ -10,10 +10,44 @@ export function socketUrl(peer, pageProtocol) {
|
||||
return `${secure ? "wss" : "ws"}://${peer}`;
|
||||
}
|
||||
|
||||
/** `#status` carries two independent facts, composed rather than overwritten:
|
||||
* a repaint must not quietly erase a connection error. */
|
||||
export function composeStatus(shell, model) {
|
||||
return [shell, model].filter(Boolean).join(" · ");
|
||||
/** How long a transient message stays on screen. Long enough to read a
|
||||
* sentence, short enough that it is gone before it is furniture. */
|
||||
export const TOAST_MS = 6000;
|
||||
|
||||
/** `ViewState::status_msg` is STATE, not an event: a repaint re-offers the
|
||||
* string that is already on screen, so dismissal keys off message IDENTITY
|
||||
* and never off a timer alone. What that costs: context/code/clients.md. */
|
||||
export function createToasts({
|
||||
onShow,
|
||||
onHide,
|
||||
ttlMs = TOAST_MS,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
}) {
|
||||
// What was last ACCEPTED, kept after the toast hides. That is the whole
|
||||
// mechanism: a repaint offering it again is recognised and dropped.
|
||||
let offered = "";
|
||||
let timer = null;
|
||||
|
||||
return {
|
||||
offer(text) {
|
||||
const next = text ?? "";
|
||||
if (next === offered) return;
|
||||
offered = next;
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
if (next === "") {
|
||||
onHide();
|
||||
return;
|
||||
}
|
||||
onShow(next);
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
onHide();
|
||||
}, ttlMs);
|
||||
},
|
||||
showing: () => timer !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The title bar's one line, in the order and spelling `src/draw.rs::draw_title`
|
||||
@@ -293,22 +327,27 @@ export function isFatal(notice) {
|
||||
}
|
||||
|
||||
/** The page's two status facts and the handshake watchdog, which share a home
|
||||
* because the one rule between them is that a *failure disarms the timer*. */
|
||||
export function createShell({ onStatus, timeoutMs, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
||||
let shell = "";
|
||||
let model = "";
|
||||
* because the one rule between them is that a *failure disarms the timer*.
|
||||
*
|
||||
* The two facts go to two SINKS and are never joined: a repaint reaches
|
||||
* `onModel` and cannot address `onShell` at all. That is the structural form
|
||||
* of the rule the `·` join used to keep by convention — context/code/clients.md. */
|
||||
export function createShell({
|
||||
onShell,
|
||||
onModel,
|
||||
timeoutMs,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
}) {
|
||||
let watchdog = null;
|
||||
let arms = 0;
|
||||
const paint = () => onStatus(composeStatus(shell, model));
|
||||
|
||||
return {
|
||||
setShell(text) {
|
||||
shell = text;
|
||||
paint();
|
||||
onShell(text);
|
||||
},
|
||||
setModel(text) {
|
||||
model = text;
|
||||
paint();
|
||||
onModel(text);
|
||||
},
|
||||
/** A peer that never answers the handshake. Deliberately about the *first
|
||||
* frame*, not either peer's own failure modes — clients.md. */
|
||||
|
||||
Reference in New Issue
Block a user