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
23 changes: 19 additions & 4 deletions public/drift/drift.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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) {
Expand All @@ -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 = ''; }
}
}

Expand Down
29 changes: 18 additions & 11 deletions public/drift/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,24 @@ <h1 class="drift-title">drift</h1>
<!-- The deck owns the gesture, not the card: the ghosts must not create
dead zones where a swipe does nothing. Two ghosts are enough to read
as a stack; more just muddies the top card. -->
<div id="drift-deck" class="drift-deck">
<i class="drift-ghost drift-ghost-2" aria-hidden="true"></i>
<i class="drift-ghost drift-ghost-1" aria-hidden="true"></i>
<!-- The bearing sits on the card BELOW, revealed as the top card is
thrown clear. It names the pole you are sending this toward, so the
gesture says where it is going while you are still deciding.
aria-hidden: it is a transient echo of the gauges, and announcing
it on every drag frame would be noise. -->
<p id="drift-bearing" class="drift-bearing" aria-hidden="true"></p>
<div id="drift-card" class="drift-card" tabindex="0" role="button"
aria-describedby="drift-hint"></div>
<!-- The y-axis gauge stands BESIDE the deck, vertical, because the swipe
that drives it is vertical. A horizontal row for an up/down axis has
no spatial relationship to the gesture and has to be read; a column
beside the card is read at a glance. -->
<div class="drift-arena">
<div id="drift-gauge-y" class="drift-gauge-column"></div>
<div id="drift-deck" class="drift-deck">
<i class="drift-ghost drift-ghost-2" aria-hidden="true"></i>
<i class="drift-ghost drift-ghost-1" aria-hidden="true"></i>
<!-- The bearing sits on the card BELOW, revealed as the top card is
thrown clear. It names the pole you are sending this toward, so the
gesture says where it is going while you are still deciding.
aria-hidden: it is a transient echo of the gauges, and announcing
it on every drag frame would be noise. -->
<p id="drift-bearing" class="drift-bearing" aria-hidden="true"></p>
<div id="drift-card" class="drift-card" tabindex="0" role="button"
aria-describedby="drift-hint"></div>
</div>
</div>
<p id="drift-edge" class="drift-edge" role="status" aria-live="polite" hidden></p>
<p id="drift-hint" class="drift-hint">swipe to move · tap to keep</p>
Expand Down
40 changes: 39 additions & 1 deletion public/drift/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions test/drift-client-guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
38 changes: 12 additions & 26 deletions test/drift-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<section id="drift-setup">
<form id="drift-seed-form"><input id="drift-seed-input" /><button type="submit">go</button></form>
<form id="drift-axis-form" hidden>
<input id="drift-axis-a-neg" /><input id="drift-axis-a-pos" />
<input id="drift-axis-b-neg" /><input id="drift-axis-b-pos" />
<div id="drift-pills"></div>
<button type="submit">set</button>
<p id="drift-axis-status"></p>
</form>
</section>
<main id="drift-stage" hidden>
<div class="drift-stage-head">
<div id="drift-gauges"></div>
<button id="drift-condensate" aria-expanded="false"><span id="drift-condensate-count">0</span></button>
</div>
<div id="drift-condensate-panel" hidden></div>
<div id="drift-deck">
<i class="drift-ghost drift-ghost-2"></i>
<i class="drift-ghost drift-ghost-1"></i>
<p id="drift-bearing"></p>
<div id="drift-card" tabindex="0" role="button"></div>
</div>
<p id="drift-edge" hidden></p>
<p id="drift-hint"></p>
</main>`;
// 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]*<body[^>]*>/, "")
.replace(/<script[\s\S]*/, "");

function served(text: string, coords: number[], seedDist = 0.4) {
return { text, tier: 1, alt: 0, seedDist, coords };
Expand Down
Loading