Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/web/src/schematic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe("schematic", () => {
{ driverTeeth: 7, drivenTeeth: 63 },
] });
const gears = [...svg.matchAll(
/<circle class="gear-node[^"]*" cx="([\d.]+)" cy="[\d.]+" r="([\d.]+)" \/>\s*<text class="tooth-label"[^>]*>(\d+)<\/text>/g,
/<circle class="gear-node[^"]*" cx="([\d.]+)" cy="[\d.]+" r="([\d.]+)"[^>]*\/>\s*<text class="tooth-label"[^>]*>(\d+)<\/text>/g,
)].map((m) => ({ cx: Number(m[1]), r: Number(m[2]), teeth: Number(m[3]) }));
expect(gears.length).toBe(4);
const byTeeth = (t: number) => gears.find((g) => g.teeth === t)!;
Expand All @@ -51,4 +51,20 @@ describe("schematic", () => {
expect(svg).toContain("idler");
expect(svg).toContain("output: same sense as driver");
});

it("carries fill/stroke as presentation attributes so shapes survive CSS being stripped (e.g. Safari Reader view)", () => {
const svg = schematicSvg({ stages: [
{ driverTeeth: 8, drivenTeeth: 59 },
{ driverTeeth: 20, drivenTeeth: 20, isIdler: true },
] });
const circles = [...svg.matchAll(/<circle class="gear-node[^"]*"[^>]*>/g)];
expect(circles.length).toBeGreaterThan(0);
// every gear circle must declare fill="none" inline — without it, a
// stylesheet-less render (CSS stripped) falls back to a solid black fill
for (const circle of circles) {
expect(circle[0]).toMatch(/fill="none"/);
expect(circle[0]).toMatch(/stroke="#[0-9a-f]+"/);
}
expect(svg).toMatch(/<line class="plate-line"[^>]*stroke="#[0-9a-f]+"[^>]*\/>/);
});
});
16 changes: 11 additions & 5 deletions packages/web/src/schematic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export function schematicSvg(train: GearTrain): string {
const width = Math.max(naturalWidth, dirLabel.length * 7.2 + PAD * 2);
const dx = (width - naturalWidth) / 2;

// Presentation attributes (fill/stroke) mirror the .gear-node CSS as a
// fallback: CSS overrides them when it's loaded, but if it's stripped
// (e.g. Safari Reader view) the shapes still outline instead of
// defaulting to a solid black fill.
const nodes = arborGears.map((gearsOnArbor, i) => {
// draw the larger gear first so a concentric pinion sits on top of it
const sorted = [...gearsOnArbor].sort((a, b) => radius(b.teeth) - radius(a.teeth));
Expand All @@ -51,15 +55,17 @@ export function schematicSvg(train: GearTrain): string {
// a solo (or the smaller concentric) gear labels at centre; a larger
// companion labels just inside its own rim so the two never collide
const labelY = sorted.length > 1 && j === 0 ? cy - r + 14 : cy + 4;
const stroke = g.idler ? "#6e695c" : "#5b574c";
const dash = g.idler ? ` stroke-dasharray="3 3"` : "";
return `
<circle class="gear-node${g.idler ? " idler" : ""}" cx="${xs[i] + dx}" cy="${cy}" r="${r}" />
<text class="tooth-label" x="${xs[i] + dx}" y="${labelY}" text-anchor="middle">${g.teeth}</text>
${g.idler ? `<text class="idler-tag" x="${xs[i] + dx}" y="${cy + rMaxAll + 14}" text-anchor="middle">idler</text>` : ""}`;
<circle class="gear-node${g.idler ? " idler" : ""}" cx="${xs[i] + dx}" cy="${cy}" r="${r}" fill="none" stroke="${stroke}" stroke-width="1.25"${dash} />
<text class="tooth-label" x="${xs[i] + dx}" y="${labelY}" text-anchor="middle" fill="#1f1d18">${g.teeth}</text>
${g.idler ? `<text class="idler-tag" x="${xs[i] + dx}" y="${cy + rMaxAll + 14}" text-anchor="middle" fill="#6e695c">idler</text>` : ""}`;
}).join("");
}).join("");

const baseline = `<line class="plate-line" x1="${PAD + dx}" y1="${cy}" x2="${naturalWidth - PAD + dx}" y2="${cy}" />`;
const dirTag = `<text class="dir-tag" x="${width - PAD}" y="${Math.max(14, cy - rMaxAll - 8)}" text-anchor="end">${dirLabel}</text>`;
const baseline = `<line class="plate-line" x1="${PAD + dx}" y1="${cy}" x2="${naturalWidth - PAD + dx}" y2="${cy}" stroke="#3f3b32" stroke-width="1" />`;
const dirTag = `<text class="dir-tag" x="${width - PAD}" y="${Math.max(14, cy - rMaxAll - 8)}" text-anchor="end" fill="#7d5516">${dirLabel}</text>`;
// Describe the train for assistive tech — role="img" makes the child <text>
// presentational, so everything a sighted user reads must be in the label.
const stageWords = train.stages
Expand Down
5 changes: 0 additions & 5 deletions packages/web/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,6 @@ thead th {
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. */
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/ui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function mountApp(root: HTMLElement): void {
root.innerHTML = `<header class="masthead">
<h1>Involute</h1>
<p class="tagline">Find the gear tooth counts for a watch complication, and see how accurate each train is.</p>
<p class="intro">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. <strong>The result below is a live example:</strong> a moon-phase train accurate to about a day in 122 years. Change the target to solve your own.</p>
<p class="intro">Pick a target — moon phase, calendar, world time. The result below is a live example; change it to solve your own.</p>
</header>
<section class="panel-slot"></section>
<p class="solve-summary"></p>
Expand Down
Loading