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(/