From 41b457c3ce4e2c72c62cf01417829bd8d5044658 Mon Sep 17 00:00:00 2001 From: ThomasBrenas <246412079+ThomasBrenas@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:56:43 +1000 Subject: [PATCH] Guide first-time visitors and use the width better Someone landing on the demo link, not the repo, had no framing: a form and a table with nothing to explain them. This adds an intro that points at the already-solved example, groups the controls into What to solve and Constraints with inline hints, marks the recommended row in text, explains the columns, and adds a Copy link button so a solved train travels as a link. Open Graph tags give the shared link a real preview. It also widens the layout so the results table fits without horizontal scroll, sizes the schematic by height so a short train no longer balloons to fill the plate, stops the direction label clipping, aligns the input fields, and rewords the below-precision note to plain language. Fixes a CSS rule that let the custom-ratio and going-train fields show on the wrong target. --- packages/engine/src/report.test.ts | 2 +- packages/engine/src/report.ts | 2 +- packages/web/index.html | 13 +++ packages/web/src/format.test.ts | 6 +- packages/web/src/schematic.ts | 28 +++-- packages/web/src/styles/app.css | 127 ++++++++++++++++++--- packages/web/src/ui/app.test.ts | 23 ++++ packages/web/src/ui/app.ts | 23 +++- packages/web/src/ui/inputPanel.ts | 137 +++++++++++++---------- packages/web/src/ui/resultsTable.test.ts | 7 ++ packages/web/src/ui/resultsTable.ts | 8 +- 11 files changed, 280 insertions(+), 96 deletions(-) diff --git a/packages/engine/src/report.test.ts b/packages/engine/src/report.test.ts index 1504b84..cafb372 100644 --- a/packages/engine/src/report.test.ts +++ b/packages/engine/src/report.test.ts @@ -37,7 +37,7 @@ describe("correction reporting", () => { it("does not hide errors larger than the absolute uncertainty", () => { // 3.1e-4 d absolute error vs 1e-6 d uncertainty: 310x the stated precision, // so a finite interval must be quoted (~1 day per ~3200 years), not - // "within the precision of the modelled constant". + // "beyond the precision of the published value". const c = correction(365.2425, 365.24219, 1e-6, "day", 1); expect(c.beyondConstantPrecision).toBe(false); expect(Number.isFinite(c.unitsPerFullError)).toBe(true); diff --git a/packages/engine/src/report.ts b/packages/engine/src/report.ts index cfdb5e1..f10e81e 100644 --- a/packages/engine/src/report.ts +++ b/packages/engine/src/report.ts @@ -14,7 +14,7 @@ export function correction( // absolute error, not the relative one, or a target known to 1e-6 days // would absorb errors ~1e-6 * period instead. if (Math.abs(errPerUnit) < uncertainty || errPerUnit === 0) { - return { unitsPerFullError: Infinity, humanInterval: "within the precision of the modelled constant", + return { unitsPerFullError: Infinity, humanInterval: "beyond the precision of the published value", direction: errPerUnit === 0 ? "exact" : (errPerUnit < 0 ? "fast" : "slow"), beyondConstantPrecision: Math.abs(errPerUnit) < uncertainty }; } diff --git a/packages/web/index.html b/packages/web/index.html index f33db8e..89b5378 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -8,6 +8,19 @@ name="description" content="Compute watch gear trains for a target ratio or period. Specify a target and constraints; Involute computes the tooth counts, the resulting error, and a schematic — every figure client-side, no network." /> + + + + + + + { }); it("formats a correction interval with direction", () => { expect(formatInterval("about 1 day every ~2.7 years", "fast")).toContain("runs fast"); - expect(formatInterval("within the precision of the modelled constant", "exact")).toBe("no correction needed"); + expect(formatInterval("beyond the precision of the published value", "exact")).toBe("no correction needed"); // below the constant's precision, no direction claim is printed expect( - formatInterval("within the precision of the modelled constant", "fast", true), - ).toBe("within the precision of the modelled constant"); + formatInterval("beyond the precision of the published value", "fast", true), + ).toBe("beyond the precision of the published value"); }); }); diff --git a/packages/web/src/schematic.ts b/packages/web/src/schematic.ts index 95fcf38..0e31567 100644 --- a/packages/web/src/schematic.ts +++ b/packages/web/src/schematic.ts @@ -29,7 +29,19 @@ export function schematicSvg(train: GearTrain): string { const rMaxAll = Math.max(...train.stages.flatMap((s) => [radius(s.driverTeeth), radius(s.drivenTeeth)])); const cy = PAD + rMaxAll; const height = cy + rMaxAll + 24; - const width = xs[xs.length - 1] + Math.max(...arborGears[k].map((g) => radius(g.teeth))) + PAD; + const naturalWidth = xs[xs.length - 1] + Math.max(...arborGears[k].map((g) => radius(g.teeth))) + PAD; + + // Mesh parity only fixes the output's sense RELATIVE to the driver — no + // absolute driver direction exists anywhere in the model, so an absolute + // CW/CCW tag would be an unverifiable claim. + const dir = outputParity(train) === 1 ? "same sense as driver" : "opposite sense to driver"; + const dirLabel = `output: ${dir}`; + // A short train's natural width is narrower than the direction label, which + // would clip when anchored at the right edge — so widen the viewBox to fit + // the label and centre the gear cluster inside it. (~7.2 px per char at the + // 10px uppercase, letter-spaced dir-tag.) + const width = Math.max(naturalWidth, dirLabel.length * 7.2 + PAD * 2); + const dx = (width - naturalWidth) / 2; const nodes = arborGears.map((gearsOnArbor, i) => { // draw the larger gear first so a concentric pinion sits on top of it @@ -40,18 +52,14 @@ export function schematicSvg(train: GearTrain): string { // companion labels just inside its own rim so the two never collide const labelY = sorted.length > 1 && j === 0 ? cy - r + 14 : cy + 4; return ` - - ${g.teeth} - ${g.idler ? `idler` : ""}`; + + ${g.teeth} + ${g.idler ? `idler` : ""}`; }).join(""); }).join(""); - const baseline = ``; - // Mesh parity only fixes the output's sense RELATIVE to the driver — no - // absolute driver direction exists anywhere in the model, so an absolute - // CW/CCW tag would be an unverifiable claim. - const dir = outputParity(train) === 1 ? "same sense as driver" : "opposite sense to driver"; - const dirTag = `output: ${dir}`; + const baseline = ``; + const dirTag = `${dirLabel}`; // Describe the train for assistive tech — role="img" makes the child // presentational, so everything a sighted user reads must be in the label. const stageWords = train.stages diff --git a/packages/web/src/styles/app.css b/packages/web/src/styles/app.css index 0c1dc5f..a7bb8de 100644 --- a/packages/web/src/styles/app.css +++ b/packages/web/src/styles/app.css @@ -26,7 +26,7 @@ body { } #app { - max-width: var(--measure); + max-width: 1120px; margin: 0 auto; padding: var(--space-8) var(--space-5) var(--space-10); display: flex; @@ -81,28 +81,75 @@ thead th { font-weight: 600; } -/* ---- Input panel ------------------------------------------------------- */ +/* ---- Intro band -------------------------------------------------------- */ +.masthead .intro { + margin: var(--space-3) 0 0; + max-width: 78ch; + color: var(--ink-soft); + font-size: var(--text-sm); + line-height: var(--leading-normal); +} + +.masthead .intro strong { + color: var(--ink); + font-weight: 600; +} + +/* ---- Input panel: two labelled groups ---------------------------------- */ +/* Groups sit side by side on wide screens and stack when the row gets tight, + so the panel uses the horizontal space instead of running down the page. */ .input-panel { display: grid; - grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); - gap: var(--space-4) var(--space-5); + grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); + gap: var(--space-5); + align-items: start; +} + +.field-group { + margin: 0; padding: var(--space-5); border: 1px solid var(--hairline); border-radius: var(--radius); background: var(--panel); } +.field-group > legend { + padding: 0 var(--space-2); + font-size: var(--text-md); + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ink); +} + +.field-group .group-note { + margin: 0 0 var(--space-4); + color: var(--ink-soft); + font-size: var(--text-sm); +} + +.group-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: var(--space-4) var(--space-5); +} + .input-panel .field { display: flex; flex-direction: column; - gap: var(--space-2); + gap: var(--space-1); min-width: 0; } -.input-panel .target-field { +.group-fields .target-field { grid-column: 1 / -1; } +.input-panel .hint { + font-size: var(--text-2xs); + color: var(--ink-faint); + line-height: var(--leading-tight); +} + .input-panel input, .input-panel select { width: 100%; @@ -126,18 +173,25 @@ button:focus-visible { cursor: pointer; } -.input-panel .custom-target[hidden] { +/* Conditional target sub-fields toggle on the hidden attribute — the specific + selector must beat `.input-panel .field { display: flex }`, so cover every + hidden descendant, not just one group. */ +.input-panel [hidden] { display: none; } .input-panel .custom-target, +.input-panel .custom-ratio-field, +.input-panel .going-train-field, .input-panel .gear-range { flex-flow: row wrap; align-items: end; gap: var(--space-2) var(--space-3); } -.input-panel .custom-target label { +.input-panel .custom-target label, +.input-panel .custom-ratio-field label, +.input-panel .going-train-field label { flex-basis: 100%; } @@ -168,11 +222,16 @@ button:focus-visible { overflow-x: auto; /* very wide trains scroll within the plate */ } +/* Size the diagram by height, not width: a 2-gear train and a 5-gear train + get the same gear scale instead of a short train stretching to fill the + plate. Wider trains cap at the plate width (and scale down); the plate + scrolls only if one is genuinely wider than the container. */ .schematic { display: block; - width: 100%; - height: auto; - min-width: 320px; + height: 220px; + width: auto; + max-width: 100%; + margin: 0 auto; } .schematic .plate-line { @@ -233,7 +292,7 @@ button:focus-visible { .results thead th { text-align: left; - padding: var(--space-3) var(--space-4); + padding: var(--space-3); border-bottom: 1px solid var(--hairline-strong); white-space: nowrap; } @@ -246,7 +305,7 @@ button:focus-visible { } .results tbody td { - padding: var(--space-3) var(--space-4); + padding: var(--space-3); border-bottom: 1px solid var(--hairline); vertical-align: baseline; } @@ -266,8 +325,16 @@ button:focus-visible { white-space: nowrap; } +/* Prose-ish columns wrap so the table fits the width; the train notation and + figures above stay on one line. */ .results td.corr { color: var(--ink-soft); + min-width: 11ch; +} + +.results td.corr, +.results td.bench-cell { + white-space: normal; } .results .bench { @@ -311,6 +378,33 @@ button:focus-visible { font-size: var(--text-sm); } +/* Visible "best" marker on the recommended row — not colour-only. */ +.best-badge { + display: inline-block; + margin-right: var(--space-1); + font-size: var(--text-2xs); + text-transform: uppercase; + letter-spacing: var(--tracking-label); + font-weight: 600; + color: var(--accent); +} + +/* Plain-language reading guide under the table. */ +.results-legend { + margin: var(--space-3) 0 0; + max-width: 62ch; + color: var(--ink-soft); + font-size: var(--text-xs); + line-height: var(--leading-normal); +} + +.schematic-caption { + margin: var(--space-2) 0 0; + color: var(--ink-soft); + font-size: var(--text-xs); + text-align: center; +} + /* ---- Export controls --------------------------------------------------- */ .export-slot { display: flex; @@ -350,10 +444,13 @@ button[data-export]:hover { gap: var(--space-4); } - /* Hide interactive controls; keep the chosen train, schematic, table. */ + /* Hide interactive controls and onboarding copy; keep the chosen train, + schematic, table, and the what-was-solved summary. */ .panel-slot, .input-panel, - .export-slot { + .export-slot, + .masthead .intro, + .results-legend { display: none !important; } diff --git a/packages/web/src/ui/app.test.ts b/packages/web/src/ui/app.test.ts index 6285ee6..006541f 100644 --- a/packages/web/src/ui/app.test.ts +++ b/packages/web/src/ui/app.test.ts @@ -19,6 +19,29 @@ describe("app", () => { history.replaceState(null, "", "#"); }); + it("teaches the cold visitor: intro, grouped controls, reading guide, share", () => { + const root = document.createElement("main"); + mountApp(root); + // intro band frames the auto-solved example + expect(root.querySelector(".intro")!.textContent).toMatch(/live example/i); + // controls are grouped into two labelled fieldsets + const legends = [...root.querySelectorAll("fieldset.field-group > legend")].map((l) => l.textContent); + expect(legends).toEqual(["What to solve", "Constraints"]); + // the recommended row carries a visible "best" badge, not colour alone + const badge = root.querySelector('tr[data-best="true"] .best-badge')!; + expect(badge).not.toBeNull(); + expect(badge.textContent).toBe("best"); + // a plain-language reading guide sits under the table + expect(root.querySelector(".results-legend")!.textContent).toMatch(/each row is one gear train/i); + // the schematic is captioned once a result exists + expect(root.querySelector(".schematic-caption")!.hidden).toBe(false); + // a Copy link button shares the pre-solved permalink + const link = root.querySelector('[data-export="link"]')!; + expect(link).not.toBeNull(); + link.click(); // must not throw even where clipboard is unavailable + history.replaceState(null, "", "#"); + }); + it("shows a readable notice for malformed input instead of throwing", () => { const root = document.createElement("main"); mountApp(root); diff --git a/packages/web/src/ui/app.ts b/packages/web/src/ui/app.ts index 3e83121..9f5b531 100644 --- a/packages/web/src/ui/app.ts +++ b/packages/web/src/ui/app.ts @@ -17,18 +17,28 @@ function debounce(fn: (...args: A) => void, ms: number): (. } export function mountApp(root: HTMLElement): void { - root.innerHTML = `

Involute

Gear trains for watchmakers, computed exactly

+ root.innerHTML = `
+

Involute

+

Find the gear tooth counts for a watch complication, and see how accurate each train is.

+

Pick what the train should track — a moon phase, a calendar, world time — and Involute computes the tooth counts and how far each option drifts. The result below is a live example: a moon-phase train accurate to about a day in 122 years. Change the target to solve your own.

+

+
-
`; +
+ + + +
`; let rows: ResultRow[] = []; let summary: SolveSummary | undefined; const resultsSlot = root.querySelector(".results-slot")!; const schematicSlot = root.querySelector(".schematic-slot")!; const status = root.querySelector(".solve-status")!; + const caption = root.querySelector(".schematic-caption")!; resultsSlot.setAttribute("aria-live", "polite"); schematicSlot.setAttribute("tabindex", "0"); schematicSlot.setAttribute("role", "region"); @@ -51,6 +61,7 @@ export function mountApp(root: HTMLElement): void { resultsSlot.replaceChildren(renderResultsTable(rows, approxMeta)); const best = rows.reduce((b, r) => (r.solution.errorRel < b.solution.errorRel ? r : b), rows[0]); schematicSlot.innerHTML = best ? schematicSvg(best.solution.train) : ""; + caption.hidden = !best; }); }; const showError = (message: string) => { @@ -76,6 +87,14 @@ export function mountApp(root: HTMLElement): void { ); panel.classList.add("input-panel"); root.querySelector(".panel-slot")!.replaceChildren(panel); + // The URL already carries the full solve (permalink), so a shared link opens a pre-solved view. + const linkBtn = root.querySelector('[data-export="link"]')!; + linkBtn.addEventListener("click", () => { + navigator.clipboard?.writeText(location.href); + const prev = linkBtn.textContent; + linkBtn.textContent = "Link copied"; + setTimeout(() => { linkBtn.textContent = prev; }, 1500); + }); root.querySelector('[data-export="json"]')!.addEventListener("click", () => navigator.clipboard?.writeText(toJson(rows, summary))); root.querySelector('[data-export="csv"]')!.addEventListener("click", () => { const blob = new Blob([toCsv(rows, summary)], { type: "text/csv" }); diff --git a/packages/web/src/ui/inputPanel.ts b/packages/web/src/ui/inputPanel.ts index 8ce30f5..a13e576 100644 --- a/packages/web/src/ui/inputPanel.ts +++ b/packages/web/src/ui/inputPanel.ts @@ -18,67 +18,82 @@ export function renderInputPanel( ): HTMLElement { const root = document.createElement("div"); root.innerHTML = ` -
- - - -
- - - -
- - -
-
- - - 2 = double-moon disc -
-
- - -
-
- - - - -
-
- - - floor for each stage's smaller gear -
+
+ What to solve +

what the gear train should track.

+
+
+ + + +
+ + + +
+ + + how often the input wheel turns; 1 = once a day +
+
+ + + periods the display spans per turn; 2 = double-moon disc +
+
+
+
+ Constraints +

limits on the gears the solver may use.

+
+
+ + + more wheels can be more accurate but more complex +
+
+ + + + + smallest and largest tooth count allowed +
+
+ + + floor for each stage's smaller gear +
+
+
`; const presetSelect = root.querySelector("#preset-select")!; diff --git a/packages/web/src/ui/resultsTable.test.ts b/packages/web/src/ui/resultsTable.test.ts index 80c2dd0..deb68d8 100644 --- a/packages/web/src/ui/resultsTable.test.ts +++ b/packages/web/src/ui/resultsTable.test.ts @@ -14,9 +14,16 @@ describe("results table", () => { const el = renderResultsTable(rows); const best = el.querySelectorAll('[data-best="true"]'); expect(best.length).toBe(1); + // the best row (k=2, lower error) carries a visible badge + expect(best[0].querySelector(".best-badge")!.textContent).toBe("best"); expect(el.textContent).toContain("classic 59-tooth train"); expect(el.querySelectorAll("tbody tr").length).toBe(2); }); + + it("includes a plain-language reading guide", () => { + const el = renderResultsTable(rows); + expect(el.querySelector(".results-legend")!.textContent).toMatch(/lower error is more accurate/i); + }); }); describe("empty state", () => { diff --git a/packages/web/src/ui/resultsTable.ts b/packages/web/src/ui/resultsTable.ts index bdd27ad..55fd6f6 100644 --- a/packages/web/src/ui/resultsTable.ts +++ b/packages/web/src/ui/resultsTable.ts @@ -15,12 +15,13 @@ export function renderResultsTable( ? formatInterval(r.correction.humanInterval, r.correction.direction, r.correction.beyondConstantPrecision) : "—"; const bench = r.benchmark ? `${r.benchmark}` : ""; - const bestMark = i === bestIdx ? `best — ` : ""; + // Visible, screen-reader-readable badge — the best row is no longer signalled by colour alone. + const bestBadge = i === bestIdx ? `best ` : ""; const period = approxMeta && r.achievedPeriodDays != null ? r.achievedPeriodDays.toFixed(approxMeta.digits) : "—"; return ` - ${bestMark}${r.solution.wheels} + ${bestBadge}${r.solution.wheels} ${formatTeeth(r.solution.train)} ${period} ${formatError(r.solution.errorRel)} @@ -33,6 +34,7 @@ export function renderResultsTable( wrap.innerHTML = `
- ${body}
Pareto frontier of candidate gear trains
WheelsTeeth (driver:driven)${periodHead}ErrorTotal teethCorrectionBenchmark
`; + ${body} +

Each row is one gear train. Fewer wheels are simpler; lower error is more accurate. "Correction" is how often you would nudge the hand to keep it right.

`; return wrap; }