From 14db49072e1fc6113ee775c5904a8df1a77a8bc0 Mon Sep 17 00:00:00 2001 From: schmug <38227427+schmug@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:27:28 -0400 Subject: [PATCH] feat: orient each gauge like the gesture that drives it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The y-axis was driven by an up/down swipe and drawn as a horizontal row, so it had no spatial relationship to the thing that moved it — you had to decode which row belonged to which direction. It now stands as a column beside the deck, and the x-axis stays a row above it. The compass reads at a glance rather than being read. Labels on the vertical gauge use writing-mode: vertical-rl. A column of horizontal words would need more width than a 390px screen can spare next to the card. Top is the NEGATIVE pole, because swiping DOWN moves toward the positive one — the label order follows the gesture rather than the convention that up means more. One gauge builder still, not two. Only the orientation differs, so the two cannot drift apart. Two things this exposed: - paintGauges queried els.gauges only. With the y gauge moved out of that container it would have silently stopped painting, leaving the vertical mark frozen at its starting point while its axis moved — a gauge lying about position. It queries both now, and a guard pins that. - The mark only transitioned `left`, so the vertical mark would have jumped rather than slid. Now both. The controller tests hand-copied the page markup into a fixture, and every one of them failed the moment the DOM changed — for reasons that had nothing to do with the controller. They now read public/drift/index.html off disk, so they exercise the page that actually ships and a markup change that breaks the controller fails in the suite rather than in production. typecheck clean; 770 tests passing, 0 failing. Co-Authored-By: Claude Opus 5 --- public/drift/drift.js | 23 ++++++++++++++---- public/drift/index.html | 29 ++++++++++++++--------- public/drift/styles.css | 40 +++++++++++++++++++++++++++++++- test/drift-client-guards.test.ts | 40 ++++++++++++++++++++++++++++++++ test/drift-controller.test.ts | 38 ++++++++++-------------------- 5 files changed, 128 insertions(+), 42 deletions(-) diff --git a/public/drift/drift.js b/public/drift/drift.js index 8f3cf76..d80320d 100644 --- a/public/drift/drift.js +++ b/public/drift/drift.js @@ -29,6 +29,7 @@ const els = { deck: document.getElementById('drift-deck'), bearing: document.getElementById('drift-bearing'), gauges: document.getElementById('drift-gauges'), + gaugeY: document.getElementById('drift-gauge-y'), condensate: document.getElementById('drift-condensate'), condensateCount: document.getElementById('drift-condensate-count'), condensatePanel: document.getElementById('drift-condensate-panel'), @@ -478,8 +479,12 @@ function renderGauges() { document.getElementById('drift-hint').textContent = state.axes.length >= 2 ? 'swipe to move · tap to keep' : 'swipe left and right to move · tap to keep'; state.axes.forEach((axis, i) => { + // Axis 1 is the up/down axis, so its gauge stands vertically beside the + // deck. Same element structure either way — only the orientation differs, + // so there is one gauge builder rather than two that can drift apart. + const vertical = i === 1; const row = document.createElement('div'); - row.className = 'drift-gauge'; + row.className = vertical ? 'drift-gauge drift-gauge--y' : 'drift-gauge'; // A degraded pole is a permanent property of the axis, not a setup-time // toast. It rides the gauge so it survives setup being hidden. if (axis.degraded) { @@ -495,19 +500,29 @@ function renderGauges() { const mark = document.createElement('i'); mark.className = 'drift-gauge-mark'; mark.dataset.axis = String(i); + mark.dataset.orient = vertical ? 'y' : 'x'; track.appendChild(mark); const hi = document.createElement('span'); hi.textContent = posTermOf(axis); row.append(lo, track, hi); - els.gauges.appendChild(row); + (vertical ? els.gaugeY : els.gauges).appendChild(row); }); + // With one axis there is no vertical gauge to show, and an empty column would + // still take its grid track and shove the deck sideways. + els.gaugeY.hidden = state.axes.length < 2; paintGauges(); } function paintGauges() { - for (const mark of els.gauges.querySelectorAll('.drift-gauge-mark')) { + // Both containers, not just els.gauges — the y-axis gauge lives beside the + // deck now, and querying only the horizontal container would leave the + // vertical mark frozen at its starting position while the axis moved. + for (const mark of document.querySelectorAll('.drift-gauge-mark')) { const a = Number(mark.dataset.axis); - mark.style.left = `${toNormalized(state.position[a], state.range, a) * 100}%`; + if (!Number.isInteger(a) || !state.range) continue; + const pct = `${toNormalized(state.position[a], state.range, a) * 100}%`; + if (mark.dataset.orient === 'y') { mark.style.top = pct; mark.style.left = ''; } + else { mark.style.left = pct; mark.style.top = ''; } } } diff --git a/public/drift/index.html b/public/drift/index.html index 1f6744d..fa909bd 100644 --- a/public/drift/index.html +++ b/public/drift/index.html @@ -74,17 +74,24 @@

drift

-
- - - - -
+ +
+
+
+ + + + +
+

swipe to move · tap to keep

diff --git a/public/drift/styles.css b/public/drift/styles.css index ad93ce8..6e9d3d7 100644 --- a/public/drift/styles.css +++ b/public/drift/styles.css @@ -245,7 +245,45 @@ .drift-surface .drift-gauge-mark { position: absolute; top: -3px; width: 7px; height: 7px; border-radius: 50%; background: var(--pin); transform: translateX(-50%); - transition: left 0.3s var(--ease); + transition: left 0.3s var(--ease), top 0.3s var(--ease); +} + +/* THE GAUGE MATCHES THE GESTURE. The x-axis is driven by a left/right swipe and + * reads as a horizontal row; the y-axis is driven by an up/down swipe and so + * stands as a column beside the deck. A horizontal row for a vertical axis has + * no spatial relationship to the thing that moves it and has to be decoded. */ +.drift-surface .drift-arena { + display: grid; + grid-template-columns: auto 1fr; + gap: 14px; + align-items: stretch; + min-height: 0; +} +.drift-surface .drift-gauge-column { display: grid; } +.drift-surface .drift-gauge-column[hidden] { display: none; } + +.drift-surface .drift-gauge--y { + grid-template-columns: none; + grid-template-rows: auto 1fr auto; + justify-items: center; + gap: 12px; +} +/* Rotated rather than stacked: a column of horizontal words would need far more + width than a 390px screen can spare next to the card. */ +.drift-surface .drift-gauge--y > span { + writing-mode: vertical-rl; + text-orientation: mixed; +} +.drift-surface .drift-gauge--y .drift-gauge-track { + width: 1px; + height: auto; + align-self: stretch; +} +/* Top is the NEGATIVE pole because swiping DOWN moves toward the positive one — + the label order follows the gesture, not the convention that up means more. */ +.drift-surface .drift-gauge--y .drift-gauge-mark { + left: 0; + transform: translate(-3px, -50%); } /* THE DECK. Two ghosts behind the card so the surface reads as a stack you can diff --git a/test/drift-client-guards.test.ts b/test/drift-client-guards.test.ts index a3c4fb2..05674ce 100644 --- a/test/drift-client-guards.test.ts +++ b/test/drift-client-guards.test.ts @@ -280,3 +280,43 @@ describe("the stylesheet does not shadow itself", () => { expect([...new Set(dupes)], "selector declared twice at base state").toEqual([]); }); }); + +describe("each gauge is oriented like the gesture that drives it", () => { + const drift = scripts.find((s) => s.name === "drift.js")!; + + it("the y-axis gauge stands beside the deck, not above it", () => { + expect(html, "no vertical gauge column").toMatch(/id="drift-gauge-y"/); + expect(html, "the deck is not in the arena beside the column") + .toMatch(/drift-arena[\s\S]{0,400}id="drift-deck"/); + }); + + it("the vertical gauge is laid out in rows, not columns", () => { + const rule = css.slice(css.indexOf(".drift-surface .drift-gauge--y {")); + const decl = rule.slice(0, rule.indexOf("}")); + expect(decl, "the y gauge is still a horizontal row").toMatch(/grid-template-rows:/); + expect(decl, "the y gauge still declares columns").toMatch(/grid-template-columns:\s*none/); + }); + + it("paintGauges positions the vertical mark on top, not left", () => { + // Setting only `left` would freeze the vertical mark at its starting point + // while its axis moved — the gauge would lie about position. + const fn = drift.source.slice(drift.source.indexOf("function paintGauges")); + const body = fn.slice(0, fn.indexOf("\n}")); + expect(body, "the vertical mark is never positioned vertically").toMatch(/style\.top\s*=/); + expect(body, "orientation is not consulted").toMatch(/dataset\.orient/); + }); + + it("paintGauges queries both gauge containers", () => { + // It used to query els.gauges only. With the y gauge moved out of that + // container, that would silently stop painting it. + const fn = drift.source.slice(drift.source.indexOf("function paintGauges")); + expect(fn.slice(0, fn.indexOf("\n}")), "only one container is queried") + .not.toMatch(/els\.gauges\.querySelectorAll/); + }); + + it("the mark transitions on both axes", () => { + const rule = css.slice(css.indexOf(".drift-surface .drift-gauge-mark {")); + expect(rule.slice(0, rule.indexOf("}")), "top is not transitioned, so the y mark jumps") + .toMatch(/transition:[^;]*top/); + }); +}); diff --git a/test/drift-controller.test.ts b/test/drift-controller.test.ts index 2ac5a96..d5d5cb0 100644 --- a/test/drift-controller.test.ts +++ b/test/drift-controller.test.ts @@ -17,34 +17,20 @@ // This drives drift.js against jsdom with fetch stubbed, so the assertions are // about what the surface DOES. +import { readFileSync } from "node:fs"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const MARKUP = ` -
-
- -
-
-
-
- -
- -
- - -

-
-
- -

-
`; +// THE REAL MARKUP, read off disk. This was a hand-copied fixture and it went +// stale the moment the DOM changed — every controller test failed at once for a +// reason that had nothing to do with the controller. Reading public/drift/index.html +// means the tests exercise the page that actually ships, and a markup change +// that breaks the controller fails HERE rather than in production. +// Path relative to the repo root, not import.meta.url: under the jsdom +// environment import.meta.url is the vite-served URL, not a file: one, and +// readFileSync rejects it. +const MARKUP = readFileSync("public/drift/index.html", "utf8") + .replace(/[\s\S]*]*>/, "") + .replace(/