Skip to content

Commit 7bc037a

Browse files
clay-goodclaude
andcommitted
feat(ui): cut an overlong lead at its first clause, not just its first period
Leading with "the first sentence" assumed a first sentence is short. It often is not: 530 of the 1,709 run past 200 characters, the longest is 615, because these descriptions pack the whole scope into one sentence behind a colon or a dash. "The bathroom rough-in check, in four numbers: 15 in from a fixture centerline to any side wall, partition, vanity, or other obstruction; 30 in center to center between ..." is the wall of text the rule was meant to prevent. leadSentence() cuts a sentence over 160 characters at its first real clause boundary, so that tile now leads with "The bathroom rough-in check, in four numbers." Median lead 158 -> 102 characters; leads over 200, 530 -> 112. Nothing is dropped: where the lead is a clause-cut summary, the detail below the answer carries the whole description rather than starting mid-sentence in lower case. Verified across all 1,709 tiles that lead + detail still reconstruct the full text. Also fixes a defect in the placeholder priming from 23be32b: on a tile whose fields carry defaults, priming computed off those defaults and painted a verdict before the reader had entered anything ("PASSES the IPC 405.3.1 clearances entered" on open). Blank-vs-filled was the wrong test. What matters is whether an answer was already on its way in -- a deep link's input events, or a click on the example button -- so that is what the restore now keys on. Verified all five open paths: plain open, ?example=1, deep link, default-prefilled tile, and clicking "Test with example". Co-Authored-By: Claude Opus 5 <[email protected]>
1 parent 23be32b commit 7bc037a

4 files changed

Lines changed: 66 additions & 27 deletions

File tree

app.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// when the user opens a tool.
99
import { verifyManifestIntegrity } from "./integrity.js";
1010
import { parseHashRoute } from "./routing.js";
11-
import { firstSentence, restOfDescription } from "./text-lead.js";
11+
import { leadSentence, restOfDescription } from "./text-lead.js";
1212

1313
// Recents (utility 120) was removed in v11; see specs/spec-v11.md.
1414

@@ -1724,7 +1724,7 @@ function renderToolView(id, params) {
17241724
// remainder goes below the answer, as `detail`.
17251725
const lead = document.createElement("p");
17261726
lead.className = "view-desc";
1727-
lead.textContent = firstSentence(tool.desc);
1727+
lead.textContent = leadSentence(tool.desc);
17281728
view.appendChild(lead);
17291729

17301730
const notice = document.createElement("div");

scripts/build-shells.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { existsSync } from "node:fs";
2424
import { resolve, dirname } from "node:path";
2525
import { fileURLToPath } from "node:url";
2626
import { CITATIONS } from "../citations.js";
27-
import { firstSentence, restOfDescription } from "../text-lead.js";
27+
import { leadSentence, restOfDescription } from "../text-lead.js";
2828

2929
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
3030
const DIST = resolve(ROOT, "dist");
@@ -111,7 +111,7 @@ function escapeHtml(s) {
111111
// word boundary; the full description is on the tile page the row links to.
112112
const GROUP_ROW_CAP = 150;
113113
function rowSummary(desc) {
114-
const s = firstSentence(desc);
114+
const s = leadSentence(desc);
115115
if (s.length <= GROUP_ROW_CAP) return s;
116116
const cut = s.slice(0, GROUP_ROW_CAP);
117117
const sp = cut.lastIndexOf(" ");
@@ -479,7 +479,7 @@ function tileShell(tool, tools, groupNames, relatedMap, examples) {
479479
' </ol>',
480480
' </nav>',
481481
` <h1 class="shell-h1">${escapeHtml(tool.name)}</h1>`,
482-
` <p class="shell-lead">${escapeHtml(firstSentence(tool.desc))}</p>`,
482+
` <p class="shell-lead">${escapeHtml(leadSentence(tool.desc))}</p>`,
483483
' <p class="shell-run">',
484484
// `?example=1` makes renderToolView load the same worked example this page
485485
// prints, so a reader who just read "awg 12 -> 24.4 A" lands on a

text-lead.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,34 @@ export function firstSentence(desc) {
3838
return s;
3939
}
4040

41-
// The description minus its first sentence, or "" when there is only one.
41+
// An opening sentence is not automatically a short one: 530 of them run past
42+
// 200 characters and the longest is 615, because these descriptions pack the
43+
// whole scope into one sentence behind a colon or a dash ("The bathroom
44+
// rough-in check, in four numbers: 15 in from a fixture centerline to ...").
45+
// A lead like that is the wall of text the one-sentence rule was meant to
46+
// avoid. So when the sentence runs long, cut it at its first real clause
47+
// boundary and let the full text carry the rest below the answer.
48+
const LEAD_CAP = 160;
49+
const CLAUSE = /(: | -- |; )/g;
50+
51+
export function leadSentence(desc) {
52+
const s = firstSentence(desc);
53+
if (s.length <= LEAD_CAP) return s;
54+
CLAUSE.lastIndex = 0;
55+
let m;
56+
while ((m = CLAUSE.exec(s))) {
57+
if (m.index >= MIN_LEAD) return s.slice(0, m.index).replace(/[,;:\s-]+$/, "") + ".";
58+
}
59+
return s;
60+
}
61+
62+
// The prose that belongs below the answer. When the lead is the whole opening
63+
// sentence, that is everything after it. When the lead is a clause-cut summary
64+
// of a longer sentence, it is the whole description: a Details block has to
65+
// read as prose, and starting one mid-sentence in lower case reads as a bug.
4266
export function restOfDescription(desc) {
4367
const s = String(desc).trim();
44-
const lead = firstSentence(s);
45-
return s.slice(lead.length).trim();
68+
const sentence = firstSentence(s);
69+
if (leadSentence(s) !== sentence) return s;
70+
return s.slice(sentence.length).trim();
4671
}

ui-fields.js

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -156,12 +156,25 @@ export function attachExampleButton(host, fillFn) {
156156
btn.type = "button";
157157
btn.className = "example-btn";
158158
btn.textContent = "Test with example";
159-
btn.addEventListener("click", fillFn);
160159
host.insertBefore(btn, host.firstChild);
160+
// Whether an answer was already on its way in before priming started. A
161+
// deep-linked tile gets real input events from applyHashState, and the
162+
// ?example=1 path clicks this button -- both are meant to show a result.
163+
// Anything else opens without one. Capture phase, so a renderer that fires
164+
// non-bubbling events is still seen.
165+
let seeded = false;
166+
const mark = () => { seeded = true; };
167+
btn.addEventListener("click", () => { mark(); fillFn(); });
168+
host.addEventListener("input", mark, true);
169+
host.addEventListener("change", mark, true);
161170
// Deferred: many tiles attach the button before they build their fields,
162171
// and the view applies deep-linked values right after the renderer returns.
163172
// A microtask runs after both, still before the first paint.
164-
queueMicrotask(() => primeExamplePlaceholders(host, fillFn));
173+
queueMicrotask(() => {
174+
host.removeEventListener("input", mark, true);
175+
host.removeEventListener("change", mark, true);
176+
primeExamplePlaceholders(host, fillFn, seeded);
177+
});
165178
return btn;
166179
}
167180

@@ -172,19 +185,19 @@ export function attachExampleButton(host, fillFn) {
172185
// rendered fields, copy what it wrote into placeholders, and put every field
173186
// back exactly as it was. Fields that already hold a value -- a tile default or
174187
// a deep-linked one -- keep it and get no placeholder.
175-
function primeExamplePlaceholders(host, fillFn) {
188+
function primeExamplePlaceholders(host, fillFn, seeded) {
176189
// The view can already be gone by the time the microtask runs -- a fast
177190
// hash change, or a catalog-wide sweep. Priming a detached region is pure
178191
// cost, so skip it.
179192
if (host.isConnected === false) return;
180193
const els = Array.from(host.querySelectorAll("input, select, textarea"));
181194
if (!els.length) return;
182195
const before = els.map((el) => ({ el, value: el.value, checked: el.checked }));
183-
// A tile opened blank must still read blank once priming is done. A tile
184-
// opened with deep-linked or default values is meant to show its answer, so
185-
// leave that answer alone.
186-
const startedBlank = before.every((snap) => !isTextish(snap.el) || !snap.value);
187-
const restoreOutputs = startedBlank ? snapshotOutputs(host) : () => {};
196+
// A tile that was not going to show an answer must not start showing one.
197+
// That includes tiles whose fields carry defaults: computing off a default
198+
// nobody chose would put a confident verdict on screen before the reader has
199+
// typed anything.
200+
const restoreOutputs = seeded ? () => {} : snapshotOutputs(host);
188201
try {
189202
fillFn();
190203
} catch {
@@ -213,16 +226,17 @@ function primeExamplePlaceholders(host, fillFn) {
213226
el.dispatchEvent(new Event("change", { bubbles: false }));
214227
}
215228
// The filler's compute is debounced, so it lands well after the restore
216-
// above. Watch the answer region and undo any write until it settles: a tile
217-
// opened blank has to stay blank, and reverting inside the observer callback
218-
// means no intermediate frame is ever painted or observable.
219-
const stillBlank = () => before.every((snap) => !isTextish(snap.el) || !snap.el.value);
220-
restoreOutputs(stillBlank);
229+
// above. Watch the answer region and undo any write until it settles;
230+
// reverting inside the observer callback means no intermediate frame is ever
231+
// painted or observable. Once a field differs from what priming put back,
232+
// the reader has typed and the answer on screen is theirs.
233+
const untouched = () => before.every((snap) => snap.el.value === snap.value && snap.el.checked === snap.checked);
234+
restoreOutputs(untouched);
221235
const out = outputRegionFor(host);
222-
if (startedBlank && out && typeof MutationObserver === "function") {
223-
const obs = new MutationObserver(() => restoreOutputs(stillBlank));
236+
if (!seeded && out && typeof MutationObserver === "function") {
237+
const obs = new MutationObserver(() => restoreOutputs(untouched));
224238
obs.observe(out, { childList: true, subtree: true, characterData: true });
225-
setTimeout(() => { restoreOutputs(stillBlank); obs.disconnect(); }, DEBOUNCE_MS * 4);
239+
setTimeout(() => { restoreOutputs(untouched); obs.disconnect(); }, DEBOUNCE_MS * 4);
226240
}
227241
}
228242

@@ -236,7 +250,7 @@ function outputRegionFor(host) {
236250
}
237251

238252
// Capture the answer region's current text so priming can put it back. The
239-
// returned restore takes a predicate: once the reader has typed something, the
253+
// returned restore takes a predicate: once the reader has changed a field, the
240254
// answer on screen is theirs and must be left alone. Returns a no-op when the
241255
// tile has no sibling output region.
242256
function snapshotOutputs(host) {
@@ -245,8 +259,8 @@ function snapshotOutputs(host) {
245259
const leaves = Array.from(out.querySelectorAll("*"))
246260
.filter((el) => !el.firstElementChild)
247261
.map((el) => ({ el, text: el.textContent }));
248-
return (stillBlank) => {
249-
if (stillBlank && !stillBlank()) return;
262+
return (untouched) => {
263+
if (untouched && !untouched()) return;
250264
for (const leaf of leaves) {
251265
if (leaf.el.textContent !== leaf.text) leaf.el.textContent = leaf.text;
252266
}

0 commit comments

Comments
 (0)