fix(as-S2): legibility — nothing fades, labels 11px, sentences 12px

From the anti-slop run (design-dev, 2026-09-28). Faded is not legible:
opacity divides whatever contrast a line had.

- Review arrows: the chip under the thin chevron is 82% dense, not 60%;
  the glyph now clears 7:1 over a white stage by colour (was 3.84:1), and
  reads at pixel level where the detector sampled a 2.9:1 median.
- Filmstrip numbers, the marks' state stamp and the inline ask's state tag
  are labels at 11px (were 9.5 / 10.5 / 10.5px).
- The Desk's section rules, the board note and the bench note are
  sentences at 12px.
- Retired benches: no opacity; the link and URL take --text-muted.
- Embed chrome: answered-ask details and the "recorded:" line inherit the
  host's text colour at full strength (the embed cannot know the host's
  palette); the notes placeholder inherits it at 75%, not the UA grey.

Folded from the heid bug-hunt: the embed's ask title no longer fades
either (Q9), and the legibility claims are also held on the browser's
COMPUTED style (tests/test_antislop_s2_browser.py): a stylesheet grep
cannot see a later rule in the cascade (font-size:1px, color:transparent,
filter:grayscale, a placeholder at opacity:0); the browser can.

Folded after the first gate run: the flagged tray's number, the tile's
"flagged" stamp and compare's A/B badge were still under the 11px label
floor; they take --size-micro too. Two film-number rows are re-anchored
on the .film-ord selector: the tray's line is now identical to it, and
the runner mutates the first match.

Contract: as_antislop S2. Falsifiers: antislop.toml 34/34 proved (S1+S2);
all 12 tables 314/314 proved on this tree.
This commit is contained in:
vh
2026-09-28 13:30:31 -07:00
parent 09071dcb65
commit 54f3531833
7 changed files with 401 additions and 18 deletions
+9 -5
View File
@@ -73,12 +73,12 @@
".bk-ask.bk-done{border-top-color:var(--bk-done)}", ".bk-ask.bk-done{border-top-color:var(--bk-done)}",
".bk-ask.bk-skip{border-top-color:var(--bk-skip)}", ".bk-ask.bk-skip{border-top-color:var(--bk-skip)}",
".bk-ask-tag{display:inline-block;margin-bottom:.6rem;padding:2px 7px;border:1.5px solid currentColor;border-radius:3px;", ".bk-ask-tag{display:inline-block;margin-bottom:.6rem;padding:2px 7px;border:1.5px solid currentColor;border-radius:3px;",
"font:600 10.5px/1.3 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;", "font:600 11px/1.3 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
"letter-spacing:.12em;text-transform:uppercase;color:var(--bk-open-text)}", "letter-spacing:.12em;text-transform:uppercase;color:var(--bk-open-text)}",
".bk-ask.bk-done .bk-ask-tag{color:var(--bk-done-text)}", ".bk-ask.bk-done .bk-ask-tag{color:var(--bk-done-text)}",
".bk-ask.bk-skip .bk-ask-tag{color:var(--bk-skip)}", ".bk-ask.bk-skip .bk-ask-tag{color:var(--bk-skip)}",
".bk-ask-title{margin:0 0 .15rem;font:500 11px/1.4 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;", ".bk-ask-title{margin:0 0 .15rem;font:500 11px/1.4 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
"letter-spacing:.12em;text-transform:uppercase;opacity:.62}", "letter-spacing:.12em;text-transform:uppercase}",
".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}", ".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}",
".bk-ask-opts{display:flex;flex-direction:column;gap:.35rem}", ".bk-ask-opts{display:flex;flex-direction:column;gap:.35rem}",
".bk-ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.55rem .7rem;cursor:pointer;", ".bk-ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.55rem .7rem;cursor:pointer;",
@@ -87,18 +87,22 @@
".bk-ask-opt:has(input:checked){border-color:var(--bk-accent-line);background:var(--bk-accent-soft)}", ".bk-ask-opt:has(input:checked){border-color:var(--bk-accent-line);background:var(--bk-accent-soft)}",
".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:var(--bk-accent)}", ".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:var(--bk-accent)}",
".bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}", ".bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}",
".bk-ask-det{font-size:.82rem;opacity:.68}", /* as S2: nothing here fades. Details inherit the HOST's text colour at full
strength, so they carry the host's contrast whatever the host is; the
smaller size is the de-emphasis. */
".bk-ask-det{font-size:.82rem}",
".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .65rem;", ".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .65rem;",
"font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);", "font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);",
"border:1px solid rgba(128,140,160,.34);border-radius:8px;resize:vertical}", "border:1px solid rgba(128,140,160,.34);border-radius:8px;resize:vertical}",
".bk-ask-notes:focus{outline:2px solid var(--bk-accent);outline-offset:1px}", ".bk-ask-notes:focus{outline:2px solid var(--bk-accent);outline-offset:1px}",
/* the host's colour at 75%, not the browser's grey (3.5-4.3:1 on a dark host) */
".bk-ask-notes::placeholder{color:inherit;opacity:.75}",
/* the primary — green, because submitting is what arms the answer */ /* the primary — green, because submitting is what arms the answer */
".bk-ask-go{margin-top:.75rem;cursor:pointer;font:600 13px/1 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;", ".bk-ask-go{margin-top:.75rem;cursor:pointer;font:600 13px/1 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"padding:.7rem 1.1rem;border-radius:8px;border:1px solid var(--bk-accent);", "padding:.7rem 1.1rem;border-radius:8px;border:1px solid var(--bk-accent);",
"background:var(--bk-accent);color:var(--bk-on-accent)}", "background:var(--bk-accent);color:var(--bk-on-accent)}",
".bk-ask-go:hover{filter:brightness(1.06)}", ".bk-ask-go:hover{filter:brightness(1.06)}",
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem;opacity:.8}", ".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem}",
".bk-ask-was b{opacity:1}",
".bk-ask-err{color:var(--bk-err);font-size:.86rem}", ".bk-ask-err{color:var(--bk-err);font-size:.86rem}",
/* [hidden] restated at class specificity: a host rule as plain as /* [hidden] restated at class specificity: a host rule as plain as
`p{display:block}` outranks the UA's own [hidden] and would show it. */ `p{display:block}` outranks the UA's own [hidden] and would show it. */
+10 -9
View File
@@ -213,7 +213,7 @@
.desk-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)} .desk-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)}
.desk-head-needs{color:var(--warning-text)} .desk-head-needs{color:var(--warning-text)}
.desk-head-new{color:var(--intel-text)} .desk-head-new{color:var(--intel-text)}
.desk-rule{font-weight:400;letter-spacing:0;text-transform:none;color:var(--text-muted)} .desk-rule{font-size:var(--size-caption);font-weight:400;letter-spacing:0;text-transform:none;color:var(--text-muted)}
.desk-row{display:flex;gap:var(--space-4);align-items:center; .desk-row{display:flex;gap:var(--space-4);align-items:center;
padding:12px;margin-bottom:8px;border:1px solid var(--border-default);border-radius:var(--radius-xl); padding:12px;margin-bottom:8px;border:1px solid var(--border-default);border-radius:var(--radius-xl);
background:var(--surface-card);box-shadow:var(--shadow-sm); background:var(--surface-card);box-shadow:var(--shadow-sm);
@@ -390,7 +390,7 @@
border-bottom:1px solid var(--border-subtle);background:var(--surface-raised); border-bottom:1px solid var(--border-subtle);background:var(--surface-raised);
font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)} font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
.mark-head code{font-size:1em} .mark-head code{font-size:1em}
.mark-state{font-family:var(--font-mono);font-size:10.5px;font-weight:600;letter-spacing:var(--tracking-caps); .mark-state{font-family:var(--font-mono);font-size:var(--size-micro);font-weight:600;letter-spacing:var(--tracking-caps);
text-transform:uppercase;line-height:1.3;padding:2px 7px;border:1.5px solid currentColor; text-transform:uppercase;line-height:1.3;padding:2px 7px;border:1.5px solid currentColor;
border-radius:var(--radius-sm);color:var(--warning-text)} border-radius:var(--radius-sm);color:var(--warning-text)}
.mark.is-answered .mark-state{color:var(--success-text);transform:rotate(-2deg)} .mark.is-answered .mark-state{color:var(--success-text);transform:rotate(-2deg)}
@@ -508,7 +508,7 @@
.tray-kind{display:flex;align-items:center;justify-content:center;height:100%;font-family:var(--font-mono); .tray-kind{display:flex;align-items:center;justify-content:center;height:100%;font-family:var(--font-mono);
font-size:var(--size-micro);text-transform:uppercase;color:var(--text-muted)} font-size:var(--size-micro);text-transform:uppercase;color:var(--text-muted)}
.tray-ord{position:absolute;left:3px;bottom:3px;padding:1px 4px;border-radius:var(--radius-sm); .tray-ord{position:absolute;left:3px;bottom:3px;padding:1px 4px;border-radius:var(--radius-sm);
font:600 10px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216)} font:600 var(--size-micro)/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216)}
.orphan-flags{margin:0;padding:8px 14px 12px;list-style:none;display:flex;flex-direction:column;gap:4px; .orphan-flags{margin:0;padding:8px 14px 12px;list-style:none;display:flex;flex-direction:column;gap:4px;
font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)} font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
.orphan-flags li{display:flex;align-items:center;justify-content:space-between;gap:8px} .orphan-flags li{display:flex;align-items:center;justify-content:space-between;gap:8px}
@@ -610,7 +610,7 @@
stamp is drawn by the tile itself, so no markup is added. */ stamp is drawn by the tile itself, so no markup is added. */
.item.is-flagged{border-color:var(--success);box-shadow:0 0 0 1px var(--success),var(--shadow-sm)} .item.is-flagged{border-color:var(--success);box-shadow:0 0 0 1px var(--success),var(--shadow-sm)}
.item.is-flagged::before{content:"flagged";position:absolute;top:10px;right:10px;z-index:2;pointer-events:none; .item.is-flagged::before{content:"flagged";position:absolute;top:10px;right:10px;z-index:2;pointer-events:none;
font:600 10.5px/1.3 var(--font-mono);letter-spacing:var(--tracking-caps);text-transform:uppercase; font:600 var(--size-micro)/1.3 var(--font-mono);letter-spacing:var(--tracking-caps);text-transform:uppercase;
padding:3px 8px;border:1.5px solid currentColor;border-radius:var(--radius-sm);color:var(--success-text); padding:3px 8px;border:1.5px solid currentColor;border-radius:var(--radius-sm);color:var(--success-text);
background:color-mix(in oklab,var(--surface-base) 88%,transparent);transform:rotate(-2deg)} background:color-mix(in oklab,var(--surface-base) 88%,transparent);transform:rotate(-2deg)}
@@ -841,7 +841,7 @@
.film-f.is-current{opacity:1;border-color:color-mix(in oklab,var(--accent) 60%,transparent)} .film-f.is-current{opacity:1;border-color:color-mix(in oklab,var(--accent) 60%,transparent)}
.film-kind{display:flex;align-items:center;justify-content:center;height:100%;font-size:20px;color:var(--text-muted)} .film-kind{display:flex;align-items:center;justify-content:center;height:100%;font-size:20px;color:var(--text-muted)}
.film-ord{position:absolute;left:3px;top:3px;padding:1px 4px;border-radius:var(--radius-sm); .film-ord{position:absolute;left:3px;top:3px;padding:1px 4px;border-radius:var(--radius-sm);
font:600 9.5px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216)} font:600 var(--size-micro)/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216)}
@media (max-width:900px){ @media (max-width:900px){
.viewer.review{position:static;display:block;min-height:100vh} .viewer.review{position:static;display:block;min-height:100vh}
.review-body{grid-template-columns:1fr} .review-body{grid-template-columns:1fr}
@@ -909,7 +909,7 @@
wears the reticle (with the active stage, via the list above). */ wears the reticle (with the active stage, via the list above). */
.film-f.is-a,.film-f.is-b{opacity:1;border-color:var(--border-strong)} .film-f.is-a,.film-f.is-b{opacity:1;border-color:var(--border-strong)}
.film-ab{position:absolute;right:3px;bottom:3px;padding:1px 5px;border-radius:var(--radius-sm); .film-ab{position:absolute;right:3px;bottom:3px;padding:1px 5px;border-radius:var(--radius-sm);
font:700 9.5px/1.2 var(--font-mono);background:var(--accent);color:var(--accent-contrast)} font:700 var(--size-micro)/1.2 var(--font-mono);background:var(--accent);color:var(--accent-contrast)}
@media (max-width:900px){ @media (max-width:900px){
.viewer.review.compare{display:block} .viewer.review.compare{display:block}
.cmp-body{grid-template-columns:minmax(0,1fr);grid-template-rows:none} .cmp-body{grid-template-columns:minmax(0,1fr);grid-template-rows:none}
@@ -930,7 +930,7 @@
border-bottom:1px solid var(--border-subtle);background:var(--surface-raised)} border-bottom:1px solid var(--border-subtle);background:var(--surface-raised)}
.board-selall{display:inline-flex;align-items:center;cursor:pointer;flex:0 0 auto} .board-selall{display:inline-flex;align-items:center;cursor:pointer;flex:0 0 auto}
.board-title{font-weight:600;font-size:var(--size-sm);color:var(--text-heading)} .board-title{font-weight:600;font-size:var(--size-sm);color:var(--text-heading)}
.board-note{font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted)} .board-note{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
/* Bulk delete: quiet until something is selected. Once rows are ticked it /* Bulk delete: quiet until something is selected. Once rows are ticked it
is ARMED with the hazard stripe — device 2, earned by the selection. */ is ARMED with the hazard stripe — device 2, earned by the selection. */
.board-del-sel{flex:0 0 auto;cursor:pointer;font-family:var(--font-mono);font-size:var(--size-micro); .board-del-sel{flex:0 0 auto;cursor:pointer;font-family:var(--font-mono);font-size:var(--size-micro);
@@ -980,13 +980,14 @@
.bench-head{display:flex;gap:10px;align-items:baseline;padding:10px 14px; .bench-head{display:flex;gap:10px;align-items:baseline;padding:10px 14px;
background:var(--surface-raised);border-bottom:1px solid var(--border-subtle)} background:var(--surface-raised);border-bottom:1px solid var(--border-subtle)}
.bench-title{font-weight:600;font-size:var(--size-sm);color:var(--text-heading)} .bench-title{font-weight:600;font-size:var(--size-sm);color:var(--text-heading)}
.bench-note{font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted)} .bench-note{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
.bench-empty{padding:12px 14px;color:var(--text-muted);font-size:var(--size-sm)} .bench-empty{padding:12px 14px;color:var(--text-muted);font-size:var(--size-sm)}
/* DAMAGED AND ABSENT MUST NOT RENDER THE SAME. */ /* DAMAGED AND ABSENT MUST NOT RENDER THE SAME. */
.bench-err{padding:12px 14px;color:var(--danger-text);background:var(--danger-soft);font-size:var(--size-sm)} .bench-err{padding:12px 14px;color:var(--danger-text);background:var(--danger-soft);font-size:var(--size-sm)}
.bench-row{display:flex;gap:10px;align-items:center;padding:9px 14px;border-top:1px solid var(--border-subtle)} .bench-row{display:flex;gap:10px;align-items:center;padding:9px 14px;border-top:1px solid var(--border-subtle)}
.bench-head+.bench-row{border-top:0} .bench-head+.bench-row{border-top:0}
.bench-row.is-retired{opacity:.5} /* retired is QUIETER, not fainter: a muted colour keeps the row legible (as S2) */
.bench-row.is-retired .bench-link,.bench-row.is-retired .bench-url{color:var(--text-muted)}
.bench-state{display:inline-flex;align-items:center;gap:6px;flex:0 0 5.6rem;justify-content:flex-start; .bench-state{display:inline-flex;align-items:center;gap:6px;flex:0 0 5.6rem;justify-content:flex-start;
font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:.08em;text-transform:uppercase; font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:.08em;text-transform:uppercase;
color:var(--text-muted)} color:var(--text-muted)}
+1 -1
View File
@@ -148,7 +148,7 @@
<style> <style>
.vnav{position:absolute;top:50%;transform:translateY(-50%);z-index:4;display:flex; .vnav{position:absolute;top:50%;transform:translateY(-50%);z-index:4;display:flex;
align-items:center;justify-content:center;width:40px;height:56px;font-size:28px; align-items:center;justify-content:center;width:40px;height:56px;font-size:28px;
line-height:1;text-decoration:none;color:oklch(0.91 0.008 216);background:oklch(0.17 0.01 250 / .6); line-height:1;text-decoration:none;color:oklch(0.91 0.008 216);background:oklch(0.17 0.01 250 / .82);
border:1px solid rgb(255 255 255 / .12);border-radius:var(--radius-lg);margin:0 8px;user-select:none; border:1px solid rgb(255 255 255 / .12);border-radius:var(--radius-lg);margin:0 8px;user-select:none;
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)} transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
+32 -3
View File
@@ -12,6 +12,7 @@ complexity: "low per slice"
touches: touches:
- "booth/app.py (filters)" - "booth/app.py (filters)"
- "booth/templates/_marks.html, _ask_inline.html, booth.html (S1)" - "booth/templates/_marks.html, _ask_inline.html, booth.html (S1)"
- "booth/templates/base.html, view.html; booth/static/embed.js (S2)"
- "tests/test_antislop.py; tests/mutations/antislop.toml" - "tests/test_antislop.py; tests/mutations/antislop.toml"
assumptions: assumptions:
- "ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume." - "ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume."
@@ -59,10 +60,38 @@ The run found that the Booth is sound on desktop and has a set of problems a vie
**Out of S1:** the CLI keeps writing its board rows as it does today. The board is a multi-writer file other sessions parse, so its storage form is not changed; `clock` reads both forms. **Out of S1:** the CLI keeps writing its board rows as it does today. The board is a multi-writer file other sessions parse, so its storage form is not changed; `clock` reads both forms.
## S2 to S6 ## S2 — legibility
These are added to this contract as each slice is staged, in the order of the report's plan: **The rule.**
- S2, legibility; - Faded is not legible. A de-emphasised line is quieter by size, weight or a muted colour, never by `opacity`: opacity takes whatever contrast the line had and divides it.
- Labels are 11px or larger (`--size-micro`).
- A sentence-like line is 12px or larger (`--size-caption`).
- **Review arrows on a light stage.** The ‹ › glyph sits on a translucent dark chip. At 60% the chip let a light stage through, and the thin glyph sampled at a median of 2.9:1 (the detector's pixel method; by colour it is about 4:1). The chip is at least 80% dense, so the glyph clears 7:1 over the lightest stage by colour, and reads at pixel level too.
- *Falsifiable:* a chip back at 60% fails `test_review_arrows_hold_over_a_white_stage`, which composites the glyph over the chip over white.
- **The filmstrip numbers are labels:** `--size-micro`, not 9.5px.
- *Falsifiable:* 9.5px fails `test_film_numbers_meet_the_label_floor`.
- **Retired benches keep their contrast.** The row carries no `opacity`. The link and URL take `--text-muted`, and the state word says RETIRED.
- *Falsifiable:* `opacity:.5` back on the row fails `test_retired_benches_are_not_faded`.
- **The marks' state stamp** (`? open`, `✓ answered`) is a label at `--size-micro`, not 10.5px.
- *Falsifiable:* `test_mark_state_meets_the_label_floor`.
- **The inline ask's state tag** (the embed chrome's `✓ answered 28 Sep 0848`) is a label at 11px, not 10.5px. S1's `<time>` made the detector measure it on its own.
- *Falsifiable:* `test_the_ask_tag_meets_the_label_floor`.
- **Hint lines are sentences:**
- the Desk's section rules (`.desk-rule`: "oldest question first", "running things");
- the board's note;
- the bench head's note.
They sit at `--size-caption`.
- *Falsifiable:* `test_hint_lines_meet_the_sentence_floor`.
- **The embed chrome fades nothing.** An answered ask's option details and its "recorded:" line inherit the host page's own text colour, with no `opacity`, so they carry the host's contrast whatever the host is. The embed cannot know the host's palette; its own palette follows the Booth theme, not the report. The notes field's placeholder inherits that colour at 75%, where it had been the browser's grey (3.5 to 4.3:1 on dark).
- *Falsifiable:* `test_embed_fades_nothing`.
- **Folded after the first gate run:** three more labels were below the 11px floor, and they are now `--size-micro` like the rest. They are the flagged tray's number (10px), the tile's "flagged" stamp (10.5px) and compare's A/B badge (9.5px). The report's list had named only the film numbers. *Falsifiable:* the same computed-style test, and three more rows.
- **Folded from the heid bug-hunt.** The embed's ask title no longer fades either (`opacity:.62` on `.bk-ask-title` contradicted "nothing fades", Q9). The claims above are also held on the browser's computed style (`tests/test_antislop_s2_browser.py`): a stylesheet grep cannot see a later rule in the cascade (`font-size:1px`, `color:transparent`, `filter:grayscale`, a placeholder at `opacity:0`), and the browser can.
## S3 to S6
These are added as each slice is staged:
- S3, phone layouts; - S3, phone layouts;
- S4, reading measure; - S4, reading measure;
- S5, interaction and screen readers; - S5, interaction and screen readers;
+144
View File
@@ -118,3 +118,147 @@ file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_board_rows_use_the_house_clock" test = "tests/test_antislop.py::test_board_rows_use_the_house_clock"
old = '''{% if e.who|byline %}<span class="board-who">{{ e.who|byline }}</span>{% endif %}''' old = '''{% if e.who|byline %}<span class="board-who">{{ e.who|byline }}</span>{% endif %}'''
new = '''{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}''' new = '''{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}'''
# ---- S2: legibility
[[mutation]]
label = "S2 the review arrows' chip lets a light stage through (60%)"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_review_arrows_hold_over_a_white_stage"
old = '''background:oklch(0.17 0.01 250 / .82);'''
new = '''background:oklch(0.17 0.01 250 / .6);'''
[[mutation]]
label = "S2 the filmstrip numbers back at 9.5px"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_film_numbers_meet_the_label_floor"
old = ''' .film-ord{position:absolute;left:3px;top:3px;padding:1px 4px;border-radius:var(--radius-sm);
font:600 var(--size-micro)/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);'''
new = ''' .film-ord{position:absolute;left:3px;top:3px;padding:1px 4px;border-radius:var(--radius-sm);
font:600 9.5px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);'''
[[mutation]]
label = "S2 the marks' state stamp back at 10.5px"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_mark_state_meets_the_label_floor"
old = ''' .mark-state{font-family:var(--font-mono);font-size:var(--size-micro);'''
new = ''' .mark-state{font-family:var(--font-mono);font-size:10.5px;'''
[[mutation]]
label = "S2 the Desk's section rules back at the label size"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_hint_lines_meet_the_sentence_floor"
old = ''' .desk-rule{font-size:var(--size-caption);font-weight:400;'''
new = ''' .desk-rule{font-weight:400;'''
[[mutation]]
label = "S2 the board note back at 11px"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_hint_lines_meet_the_sentence_floor"
old = ''' .board-note{font-family:var(--font-mono);font-size:var(--size-caption);'''
new = ''' .board-note{font-family:var(--font-mono);font-size:var(--size-micro);'''
[[mutation]]
label = "S2 the bench note back at 11px"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_hint_lines_meet_the_sentence_floor"
old = ''' .bench-note{font-family:var(--font-mono);font-size:var(--size-caption);'''
new = ''' .bench-note{font-family:var(--font-mono);font-size:var(--size-micro);'''
[[mutation]]
label = "S2 retired benches faded again"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_retired_benches_are_not_faded"
old = ''' .bench-row.is-retired .bench-link,.bench-row.is-retired .bench-url{color:var(--text-muted)}'''
new = ''' .bench-row.is-retired{opacity:.5}'''
[[mutation]]
label = "S2 an answered ask's option details faded again"
file = "booth/static/embed.js"
test = "tests/test_antislop.py::test_embed_fades_nothing"
old = ''' ".bk-ask-det{font-size:.82rem}",'''
new = ''' ".bk-ask-det{font-size:.82rem;opacity:.68}",'''
[[mutation]]
label = "S2 the recorded line faded again"
file = "booth/static/embed.js"
test = "tests/test_antislop.py::test_embed_fades_nothing"
old = ''' ".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem}",'''
new = ''' ".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem;opacity:.8}",'''
[[mutation]]
label = "S2 the notes placeholder back to the browser's grey"
file = "booth/static/embed.js"
test = "tests/test_antislop.py::test_embed_fades_nothing"
old = ''' ".bk-ask-notes::placeholder{color:inherit;opacity:.75}",'''
new = ''''''
[[mutation]]
label = "S2 the inline ask's state tag back at 10.5px"
file = "booth/static/embed.js"
test = "tests/test_antislop.py::test_the_ask_tag_meets_the_label_floor"
old = """ "font:600 11px/1.3 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;","""
new = """ "font:600 10.5px/1.3 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;","""
# ---- S2, folded from the heid bug-hunt guard grid: what the COMPUTED style shows
[[mutation]]
label = "S2 the ask title fades (Q9)"
file = "booth/static/embed.js"
test = "tests/test_antislop_s2_browser.py::test_nothing_fades"
old = """ "letter-spacing:.12em;text-transform:uppercase}","""
new = """ "letter-spacing:.12em;text-transform:uppercase;opacity:.62}","""
[[mutation]]
label = "S2 a later rule shrinks the film numbers (cascade, not shorthand)"
file = "booth/templates/base.html"
test = "tests/test_antislop_s2_browser.py::test_labels_and_sentences_render_at_their_floors"
old = """ .film-ord{position:absolute;left:3px;top:3px;padding:1px 4px;border-radius:var(--radius-sm);
font:600 var(--size-micro)/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216)}"""
new = """ .film-ord{position:absolute;left:3px;top:3px;padding:1px 4px;border-radius:var(--radius-sm);
font:600 var(--size-micro)/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216);font-size:1px}"""
[[mutation]]
label = "S2 the details go transparent without any opacity"
file = "booth/static/embed.js"
test = "tests/test_antislop_s2_browser.py::test_nothing_fades"
old = """ ".bk-ask-det{font-size:.82rem}","""
new = """ ".bk-ask-det{font-size:.82rem;color:transparent}","""
[[mutation]]
label = "S2 the placeholder vanishes (opacity 0) while keeping color:inherit"
file = "booth/static/embed.js"
test = "tests/test_antislop_s2_browser.py::test_nothing_fades"
old = """ ".bk-ask-notes::placeholder{color:inherit;opacity:.75}","""
new = """ ".bk-ask-notes::placeholder{color:inherit;opacity:0}","""
[[mutation]]
label = "S2 retired benches greyed by a filter instead of opacity"
file = "booth/templates/base.html"
test = "tests/test_antislop_s2_browser.py::test_nothing_fades"
old = """ .bench-row.is-retired .bench-link,.bench-row.is-retired .bench-url{color:var(--text-muted)}"""
new = """ .bench-row.is-retired .bench-link,.bench-row.is-retired .bench-url{color:var(--text-muted)}
.bench-row.is-retired{filter:grayscale(.6) opacity(.5)}"""
[[mutation]]
label = "S2 the flagged tray's number back at 10px"
file = "booth/templates/base.html"
test = "tests/test_antislop_s2_browser.py::test_labels_and_sentences_render_at_their_floors"
old = ''' .tray-ord{position:absolute;left:3px;bottom:3px;padding:1px 4px;border-radius:var(--radius-sm);
font:600 var(--size-micro)/1.2 var(--font-mono);'''
new = ''' .tray-ord{position:absolute;left:3px;bottom:3px;padding:1px 4px;border-radius:var(--radius-sm);
font:600 10px/1.2 var(--font-mono);'''
[[mutation]]
label = "S2 the tile's flagged stamp back at 10.5px"
file = "booth/templates/base.html"
test = "tests/test_antislop_s2_browser.py::test_labels_and_sentences_render_at_their_floors"
old = ''' font:600 var(--size-micro)/1.3 var(--font-mono);letter-spacing:var(--tracking-caps);text-transform:uppercase;'''
new = ''' font:600 10.5px/1.3 var(--font-mono);letter-spacing:var(--tracking-caps);text-transform:uppercase;'''
[[mutation]]
label = "S2 compare's A/B badge back at 9.5px"
file = "booth/templates/base.html"
test = "tests/test_antislop_s2_browser.py::test_labels_and_sentences_render_at_their_floors"
old = ''' font:700 var(--size-micro)/1.2 var(--font-mono);background:var(--accent);color:var(--accent-contrast)}'''
new = ''' font:700 9.5px/1.2 var(--font-mono);background:var(--accent);color:var(--accent-contrast)}'''
+109
View File
@@ -174,3 +174,112 @@ def test_a_malformed_answer_costs_its_line_not_the_page(client):
(b / ".marks.json").write_text(_json.dumps(doc)) (b / ".marks.json").write_text(_json.dumps(doc))
for url in ("/b/b/", "/b/b/marks"): for url in ("/b/b/", "/b/b/marks"):
assert c.get(url).status_code == 200, url assert c.get(url).status_code == 200, url
# ---- S2: legibility ----------------------------------------------------------------
# Faded is not legible; labels >= 11px (--size-micro); sentence-like lines >= 12px
# (--size-caption). These read the shipped CSS, and the arrow test composites the
# colours with INDEPENDENT maths (OKLCH -> sRGB -> WCAG), not with anything the
# page computes.
import math as _math
from pathlib import Path as _Path
_T = _Path(__file__).resolve().parent.parent / "booth" / "templates"
_EMBED = _Path(__file__).resolve().parent.parent / "booth" / "static" / "embed.js"
def _rule(css: str, selector: str) -> str:
m = re.search(r"(?:^|[}\s])" + re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m, f"no rule for {selector!r}"
return m.group(1)
def _oklch_srgb(l, c, h):
a, b = c * _math.cos(_math.radians(h)), c * _math.sin(_math.radians(h))
l_, m_, s_ = (l + 0.3963377774 * a + 0.2158037573 * b, l - 0.1055613458 * a - 0.0638541728 * b,
l - 0.0894841775 * a - 1.2914855480 * b)
L, M, S = l_ ** 3, m_ ** 3, s_ ** 3
lin = (4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S,
-1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S,
-0.0041960863 * L - 0.7034186147 * M + 1.7076147010 * S)
enc = lambda v: 12.92 * v if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055
return [max(0.0, min(1.0, enc(v))) for v in lin]
def _lum(rgb):
f = lambda v: v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2])
def _oklch(decl: str, prop: str):
m = re.search(prop + r"\s*:\s*oklch\(([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*/\s*([\d.]+))?\)", decl)
assert m, f"{prop} is not an oklch() colour in {decl!r}"
return _oklch_srgb(*map(float, m.groups()[:3])), float(m.group(4) or 1)
def test_review_arrows_hold_over_a_white_stage():
"""The worst stage is white: composite the chip over it in sRGB, as the
browser does, and measure the glyph against the result."""
decl = _rule((_T / "view.html").read_text(), ".vnav")
glyph, _ = _oklch(decl, r"(?<![-\w])color")
chip, alpha = _oklch(decl, "background")
under = [alpha * c + (1 - alpha) * 1.0 for c in chip]
a, b = _lum(glyph), _lum(under)
ratio = (max(a, b) + 0.05) / (min(a, b) + 0.05)
assert ratio >= 7, f"arrow glyph over the chip over white: {ratio:.2f}:1"
def _px(decl: str) -> float:
m = re.search(r"font(?:-size)?\s*:[^;]*?(?:(\d+(?:\.\d+)?)px|var\(--size-(micro|caption|sm|body)\))", decl)
assert m, decl
return float(m.group(1)) if m.group(1) else {"micro": 11, "caption": 12, "sm": 13, "body": 14}[m.group(2)]
def test_film_numbers_meet_the_label_floor():
assert _px(_rule((_T / "base.html").read_text(), ".film-ord")) >= 11
def test_mark_state_meets_the_label_floor():
assert _px(_rule((_T / "base.html").read_text(), ".mark-state")) >= 11
@pytest.mark.parametrize("selector", [".desk-rule", ".board-note", ".bench-note"])
def test_hint_lines_meet_the_sentence_floor(selector):
css = (_T / "base.html").read_text()
decls = [m.group(1) for m in re.finditer(r"(?:^|[}\s])" + re.escape(selector) + r"\s*\{([^}]*)\}", css)]
sizes = [_px(d) for d in decls if "font" in d]
assert sizes and min(sizes) >= 12, (selector, sizes)
def test_retired_benches_are_not_faded():
css = (_T / "base.html").read_text()
for m in re.finditer(r"([^{}]*is-retired[^{}]*)\{([^}]*)\}", css):
assert "opacity" not in m.group(2), m.group(0)
def _js_rule(js: str, selector: str) -> str:
"""embed.js carries its sheet as JS string literals: `".sel{...}"`."""
m = re.search(r'"' + re.escape(selector) + r"\{([^}]*)\}", js)
assert m, f"no rule for {selector!r} in embed.js"
return m.group(1)
def test_embed_fades_nothing():
js = _EMBED.read_text()
for sel in (".bk-ask-det", ".bk-ask-was", ".bk-ask-title"):
decl = _js_rule(js, sel)
assert "opacity" not in decl, (sel, decl)
assert not re.search(r'"\.bk-ask-was b\{', js), "a child restoring opacity means the parent fades"
ph = _js_rule(js, ".bk-ask-notes::placeholder")
assert "color:inherit" in ph.replace(" ", ""), ph
def test_the_ask_tag_meets_the_label_floor():
"""The inline ask's state tag is a label (`? your pick`, `✓ answered 28 Sep 0848`).
Its sheet is split over JS string literals, so read the font line that follows
the selector."""
js = _EMBED.read_text()
i = js.index('".bk-ask-tag{')
m = re.search(r"font:\d+ (\d+(?:\.\d+)?)px", js[i:i + 400])
assert m and float(m.group(1)) >= 11, m and m.group(0)
+96
View File
@@ -0,0 +1,96 @@
"""S2 of the anti-slop fix slices, read off the browser's COMPUTED style.
The string tests in test_antislop.py read the stylesheet; a later rule in the
cascade (`font-size:1px`, `color:transparent`, `filter:grayscale`, a placeholder
at `opacity:0`) passes them and is still illegible (heid bug-hunt guard grid).
These open the pages in the real uvicorn + Chromium harness and read what the
browser will actually paint. Skips, never fails, when no usable Chromium exists.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from test_flow_browser import _png, browser, live # noqa: E402,F401 (fixtures)
WIDE = {"width": 1280, "height": 800}
def _seed(root: pathlib.Path):
from booth.benches import set_bench_state, upsert_bench
from booth.links import LINKS_FILE
from booth.marks import answer_pick, declare_pick, marks_for
g = root / "g"
g.mkdir()
for n in ("a.png", "b.png"):
(g / n).write_bytes(_png(64, 48))
from booth.marks import set_flag
set_flag(g, "a.png", True) # the tray's number, the tile's stamp
declare_pick(g, "p1", {"prompt": "Which?", "options": ["North", "South"]})
answer_pick(g, "p1", "North", who="127.0.0.1")
r = root / "r"
r.mkdir()
(r / "index.html").write_text("<!doctype html><title>r</title><body><h1>R</h1><p>body</p></body>")
declare_pick(r, "p2", {"title": "Round one", "prompt": "Which gate?", "notes": True,
"options": [{"id": "n", "label": "North", "detail": "the gate by the road"},
{"id": "s", "label": "South", "detail": "the loading dock"}]})
answer_pick(r, "p2", marks_for(r)[0].options[0]["id"], who="127.0.0.1")
board = root / "links"
board.mkdir()
(board / LINKS_FILE).write_text("- [x](http://x.example/) <sub>· infra-ops · 2026-09-06 23:35</sub>\n")
bench, _ = upsert_bench(root, "http://10.100.10.50:7788/talk", "talk demo", "booth")
set_bench_state(root, bench.id, "retired")
def _size(page, sel):
return page.locator(sel).first.evaluate("e => parseFloat(getComputedStyle(e).fontSize)")
def test_labels_and_sentences_render_at_their_floors(browser, live):
base, root = live
_seed(root)
page = browser.new_page(viewport=WIDE)
checks = [("/b/g/view?f=a.png", ".film-ord", 11), ("/b/g/marks", ".mark-state", 11), ("/", ".desk-rule", 12),
("/b/links/", ".board-note", 12), ("/b/links/", ".bench-note", 12)]
for url, sel, floor in checks:
page.goto(base + url, wait_until="load")
assert _size(page, sel) >= floor, (url, sel, _size(page, sel))
# folded after the S2 gate: three labels the report's list did not name
for url, sel in (("/b/g/", ".tray-ord"), ("/b/g/compare?a=a.png&b=b.png", ".film-ab")):
page.goto(base + url, wait_until="load")
assert _size(page, sel) >= 11, (url, sel, _size(page, sel))
page.goto(base + "/b/g/", wait_until="load")
stamp = page.locator(".item.is-flagged").first.evaluate(
"e => parseFloat(getComputedStyle(e, '::before').fontSize)")
assert stamp >= 11, ("the tile's 'flagged' stamp", stamp)
page.goto(base + "/b/r/", wait_until="load")
page.wait_for_selector(".bk-ask-tag")
assert _size(page, ".bk-ask-tag") >= 11
page.close()
_FADE = """e => { const cs = getComputedStyle(e), parent = getComputedStyle(e.parentElement);
const a = c => { const m = c.match(/rgba?\\(([^)]+)\\)/); if (!m) return 1; const p = m[1].split(',');
return p.length > 3 ? parseFloat(p[3]) : 1; };
return {opacity: parseFloat(cs.opacity), filter: cs.filter, alpha: a(cs.color),
color: cs.color, parent: parent.color}; }"""
def test_nothing_fades(browser, live):
base, root = live
_seed(root)
page = browser.new_page(viewport=WIDE)
page.goto(base + "/b/r/", wait_until="load")
page.wait_for_selector(".bk-ask-det")
for sel in (".bk-ask-det", ".bk-ask-was", ".bk-ask-title"):
got = page.locator(sel).first.evaluate(_FADE)
assert got["opacity"] == 1 and got["filter"] == "none" and got["alpha"] == 1, (sel, got)
det = page.locator(".bk-ask-det").first.evaluate(_FADE)
assert det["color"] == det["parent"], ("details take the host's own colour", det)
ph = page.locator(".bk-ask-notes").first.evaluate("""e => { const p = getComputedStyle(e, '::placeholder');
return {opacity: parseFloat(p.opacity), color: p.color, field: getComputedStyle(e).color}; }""")
assert ph["opacity"] >= 0.7 and ph["color"] == ph["field"], ph
page.goto(base + "/b/links/", wait_until="load")
row = page.locator(".bench-row.is-retired").first.evaluate(_FADE)
assert row["opacity"] == 1 and row["filter"] == "none", row
page.close()