Public: anyone can read this; the crew can change it.

The Iterations page fits a 390 px screen: the tree scrolls inside its canvas, the page itself never scrolls sideways, and every control is reachable #9175

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9128-the-iterations-page-fits-a-390-px-screen → main 4 commits fork 6 threads · 6 unresolved

  • Merged. scout/9128-the-iterations-page-fits-a-390-px-screen was merged into main as 385f056.

Review

Sign in to review this change.
2 files changed +109 −6
src/web/pages/iterations.ts +19 −5
@@ -140,6 +140,16 @@ body.it-nosel{user-select:none;-webkit-user-select:none}
140140 .it-page[data-notes="0"] button.it-b .it-n,.it-page[data-notes="0"] .it-notes{display:none}
141141 .it-pane{display:grid;grid-template-columns:320px minmax(0,1fr);gap:16px;margin-top:16px;align-items:start}
142142 @media (max-width:760px){.it-pane{grid-template-columns:minmax(0,1fr)}}
outdated

This is a guess at the cause. Identify the element that is wider than the viewport at 390 px and constrain that one, then test it. Without that, box 1 cannot be ticked.

143 /* A phone. The page never scrolls sideways (base.ts clips the body); what is wider than the screen scrolls inside .it-stage.
144 The one box wider than a 390 px screen is a card (50ch of mono is about 400 px): hold it to the canvas, 100vw less the
145 shell's padding (2 x 16), the stage's padding (2 x 16) and its border (2). The toolbar wraps and its buttons are 44 px. */
outdated

Move the phone rules into their own @media block; the first rule shares a line with .it-pane's, which is hard to read and to edit.

146 @media (max-width:760px){
147 #it-grid{--it-card:min(calc(50ch + 26px),calc(100vw - 66px))}
outdated

Ties in specificity with .btn[data-size="sm"] (min-height 28/36px). Raise it so 44px always wins, e.g. .it-page .it-zc .btn[data-size="sm"].

148 .it-key .it-sp{margin-left:0;width:100%}
149 .it-zc{flex-wrap:wrap;gap:6px;margin-right:0}
150 .it-page .it-zc .btn[data-size="sm"],.it-page .it-sp > .btn[data-size="sm"]{min-height:44px;min-width:44px}
151 .it-seg a{display:inline-flex;align-items:center;min-height:44px}
152 }
143153 `;
144154
145155 /**
@@ -180,11 +190,15 @@ export const EDGES_JS = `
180190 * in this browser. `zoom(stage, box, grid, redraw)` returns { z, fit } where
181191 * z() is the scale and fit() resizes the scrolled box to the scaled graph.
182192 */
193 export const ZOOM_MIN = 0.1;
194 /** Fit alone may go below ZOOM_MIN, so a very wide tree still fits whole; a saved zoom reopens at ZOOM_MIN or more. */
195 export const ZOOM_FLOOR = 0.01;
196
outdated

The saved-zoom clamp now allows FLOOR. A Fit result stored in localStorage will reopen at 1% on a desktop. Clamp the stored value to ZOOM_MIN, or do not store the Fit scale.

183197 export const ZOOM_JS = `
184198 function zoom(stage, box, grid, redraw) {
185 var STEPS = [0.25, 0.33, 0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 2], KEY = 'forge:iterations-zoom';
199 var MIN = ${ZOOM_MIN}, FLOOR = ${ZOOM_FLOOR}, STEPS = [0.25, 0.33, 0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 2], KEY = 'forge:iterations-zoom';
186200 var Z = 1;
187 try { Z = Math.min(2, Math.max(0.2, Number(localStorage.getItem(KEY)) || 1)); } catch (e) { Z = 1; }
201 try { Z = Math.min(2, Math.max(MIN, Number(localStorage.getItem(KEY)) || 1)); } catch (e) { Z = 1; }
188202 var val = document.getElementById('it-zval');
outdated

Floor 0.1 still leaves Fit unable to fit a tree wider than ~10x the canvas. Let Fit compute its own scale without this floor.

189203 function fit() {
190204 grid.style.transform = Z === 1 ? '' : 'scale(' + Z + ')';
@@ -192,8 +206,8 @@ export const ZOOM_JS = `
192206 if (val) val.textContent = Math.round(Z * 100) + '%';
193207 }
194208 // Change the scale, keeping the point (px, py) of the stage where it is: its centre unless given.
195 function set(z, px, py) {
196 z = Math.min(2, Math.max(0.2, Math.round(z * 100) / 100));
209 function set(z, px, py, free) {
210 z = Math.min(2, Math.max(free ? FLOOR : MIN, Math.round(z * 100) / 100));
197211 if (z === Z) return;
198212 if (px === undefined) { px = stage.clientWidth / 2; py = stage.clientHeight / 2; }
199213 var gx = (stage.scrollLeft + px) / Z, gy = (stage.scrollTop + py) / Z;
@@ -216,7 +230,7 @@ export const ZOOM_JS = `
216230 // The height the stage may grow to, not the height it has: a stage that shrank to a small graph would shrink it again on every Fit.
217231 var tall = parseFloat(cs.maxHeight); if (!(tall > 0)) tall = window.innerHeight * 0.6;
218232 var bw = parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth), bh = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
219 set(Math.floor(Math.min(1, (stage.clientWidth - padX - 2) / w, (tall - bh - padY - 2) / h) * 100) / 100);
233 set(Math.floor(Math.min(1, (stage.clientWidth - padX - 2) / w, (tall - bh - padY - 2) / h) * 100) / 100, undefined, undefined, true);
220234 stage.scrollLeft = 0; stage.scrollTop = 0;
221235 });
222236 stage.addEventListener('wheel', function (e) {
test/iterations.test.ts +90 −1
@@ -18,7 +18,9 @@ import { promote } from "./helpers/members.ts";
1818 import { serve, type Servers } from "../src/http/server.ts";
1919 import { familyRepos } from "../src/core/lineage.ts";
2020 import { lanesOf, type IterationBuild, type IterationFork } from "../src/core/iterations.ts";
21 import { ITERATIONS_CSS, ITERATIONS_JS } from "../src/web/pages/iterations.ts";
21 import { ITERATIONS_CSS, ITERATIONS_JS, ZOOM_JS, ZOOM_MIN } from "../src/web/pages/iterations.ts";
22 import { components as STYLE_CSS } from "../src/web/style/components.ts";
23 import { base as BASE_CSS } from "../src/web/style/base.ts";
2224
2325 const build = (repo: string, label: string, parent: string | null = null, edge: IterationBuild["edge"] = parent === null ? null : "git"): IterationBuild => ({
2426 key: `${repo}:${label}`,
@@ -163,6 +165,93 @@ describe("iterations page", () => {
163165 expect(columns).not.toContain('"lanes"');
164166 });
165167
168 /** The page's rules as plain data: the media query each sits in ("" at the top), its selectors and its declarations. */
169 const rulesOf = (css: string) => {
170 const out: { media: string; sel: string[]; decl: string }[] = [];
171 const body = css.replace(/\/\*[\s\S]*?\*\//g, "");
172 let media = "", depth = 0, i = 0;
173 while (i < body.length) {
174 const open = body.indexOf("{", i), close = body.indexOf("}", i);
175 if (close === -1) break;
outdated

String-contains checks do not show 44 px wins or that the rules are inside the 760px media query. Assert the structure and the cascade.

176 if (open !== -1 && open < close) {
177 const head = body.slice(i, open).trim();
178 if (head.startsWith("@media")) { media = head; depth = 1; i = open + 1; continue; }
179 const end = body.indexOf("}", open);
180 out.push({ media: depth ? media : "", sel: head.split(",").map((x) => x.trim()), decl: body.slice(open + 1, end) });
181 i = end + 1;
182 } else { depth = 0; media = ""; i = close + 1; }
183 }
184 return out;
185 };
186 const PHONE = "@media (max-width:760px)";
187
188 test("a 390 px screen: the tree scrolls inside .it-stage, nothing around it is held wider, and a card is held to the canvas", async () => {
189 const page = await (await get("/alice/game/iterations")).text();
190 const at = (s: string) => page.indexOf(s);
191 expect(at('class="it-page"')).toBeGreaterThan(-1);
192 expect(at('class="it-page"')).toBeLessThan(at('class="it-stage" id="it-stage"'));
193 expect(at('class="it-stage" id="it-stage"')).toBeLessThan(at('class="it-zoom" id="it-zoom"'));
194 expect(at('class="it-zoom" id="it-zoom"')).toBeLessThan(at('id="it-grid"'));
195 expect(BASE_CSS).toContain("body { overflow-x: clip; }");
196 const rules = rulesOf(ITERATIONS_CSS);
197 const of = (sel: string) => rules.filter((r) => r.sel.includes(sel));
198 expect(of(".it-stage").some((r) => r.media === "" && /(^|;)overflow:auto(;|$)/.test(r.decl))).toBe(true);
199 for (const box of [".it-page", ".it-stage", ".it-zoom"]) for (const r of of(box)) expect(r.decl).not.toMatch(/(^|;)(min-)?width:/);
200 // The card, 50ch of mono (about 400 px), is the one box wider than the screen: on a phone it is the canvas less the
201 // shell's and the stage's padding and the stage's border.
202 const card = rules.findIndex((r) => r.media === PHONE && r.sel.includes("#it-grid") && r.decl.includes("--it-card:min(calc(50ch + 26px),calc(100vw - 66px))"));
203 expect(card).toBeGreaterThan(rules.findIndex((r) => r.media === "" && r.sel.includes("#it-grid") && r.decl.includes("--it-card:")));
204 expect(of(".it-pane").find((r) => r.media === PHONE)!.decl).toBe("grid-template-columns:minmax(0,1fr)");
205 });
206
207 test("a phone: the toolbar wraps in its own media block, and its 44 px rule outranks .btn[data-size=sm]", async () => {
208 const page = await (await get("/alice/game/iterations?layout=tree")).text();
209 expect(page).toContain('class="it-zc"');
210 for (const id of ["it-fold-all", "it-open-all", "it-zout", "it-zval", "it-zin", "it-zfit", "it-align", "it-reset"]) expect(page).toContain(`id="${id}"`);
211 const rules = rulesOf(ITERATIONS_CSS);
212 const pane = rules.find((r) => r.sel.includes(".it-pane") && r.media === PHONE)!;
213 const block = rules.filter((r) => r.media === PHONE && r !== pane);
214 expect(ITERATIONS_CSS).toContain("@media (max-width:760px){.it-pane{grid-template-columns:minmax(0,1fr)}}\n");
215 expect(block.find((r) => r.sel.includes(".it-zc"))!.decl).toContain("flex-wrap:wrap");
216 expect(block.find((r) => r.sel.includes(".it-key .it-sp"))!.decl).toContain("width:100%");
217 const big = block.find((r) => /min-height:44px/.test(r.decl) && r.sel.some((x) => x.includes(".it-zc .btn")))!;
218 expect(big.sel.length).toBe(2);
219 const weight = (sel: string) => {
220 const attrs = (sel.match(/\[[^\]]*\]/g) ?? []).length;
221 const rest = sel.replace(/\[[^\]]*\]/g, "");
222 return attrs + (rest.match(/[.:]/g) ?? []).length + (rest.match(/#/g) ?? []).length * 100;
223 };
224 const btn = Math.max(...[...STYLE_CSS.matchAll(/(\.btn\[data-size="sm"\])\s*\{[^}]*min-height/g)].map((m) => weight(m[1]!)));
225 expect(btn).toBeGreaterThan(0);
226 for (const sel of big.sel) expect(weight(sel)).toBeGreaterThan(btn);
227 });
228
229 test("Fit makes a very wide tree fit whole, below the buttons' zoom floor", () => {
230 const make = new Function("document", "localStorage", "getComputedStyle", "window", ZOOM_JS + "; return zoom;");
231 const handlers: Record<string, () => void> = {};
232 const doc = { getElementById: (id: string) => ({ textContent: "", addEventListener: (_: string, f: () => void) => { handlers[id] = f; } }) };
233 const store = { getItem: () => null, setItem() {}, removeItem() {} };
234 const cs = () => ({ paddingLeft: "16", paddingRight: "16", paddingTop: "16", paddingBottom: "16", maxHeight: "480", borderLeftWidth: "1", borderRightWidth: "1", borderTopWidth: "1", borderBottomWidth: "1" });
235 const stage = { clientWidth: 358, clientHeight: 400, scrollLeft: 0, scrollTop: 0, addEventListener() {} };
236 const grid = { offsetWidth: 20000, offsetHeight: 300, style: {} as Record<string, string> };
237 const box = { style: {} as Record<string, string> };
238 const z = make(doc, store, cs, { innerHeight: 800 })(stage, box, grid, () => {});
239 handlers["it-zfit"]!();
240 expect(z.z()).toBeLessThan(ZOOM_MIN);
241 expect(20000 * z.z()).toBeLessThanOrEqual(358 - 32);
242 handlers["it-zin"]!();
243 expect(z.z()).toBeGreaterThanOrEqual(ZOOM_MIN);
244 });
245
246 test("a Fit below ZOOM_MIN saved in this browser reopens at ZOOM_MIN, not at 1%", () => {
247 const make = new Function("document", "localStorage", "getComputedStyle", "window", ZOOM_JS + "; return zoom;");
248 const doc = { getElementById: () => ({ textContent: "", addEventListener() {} }) };
249 const store = { getItem: () => "0.01", setItem() {}, removeItem() {} };
250 const stage = { clientWidth: 1200, clientHeight: 600, scrollLeft: 0, scrollTop: 0, addEventListener() {} };
251 const z = make(doc, store, () => ({}), { innerHeight: 800 })(stage, { style: {} }, { offsetWidth: 800, offsetHeight: 300, style: {} }, () => {});
252 expect(z.z()).toBe(ZOOM_MIN);
253 });
254
166255 test("the markup the script relies on is there for a folded run", async () => {
167256 const text = await (await get("/alice/game/iterations?layout=tree")).text();
168257 expect(text).toContain('data-lane="alice/game" data-repo="alice/game"');

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs