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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9128-the-iterations-page-fits-a-390-px-screen → main 4 commits fork 6 threads · 6 unresolved
src/web/pages/iterations.ts +19 −5
| @@ -140,6 +140,16 @@ body.it-nosel{user-select:none;-webkit-user-select:none} | ||
| 140 | 140 | .it-page[data-notes="0"] button.it-b .it-n,.it-page[data-notes="0"] .it-notes{display:none} |
| 141 | 141 | .it-pane{display:grid;grid-template-columns:320px minmax(0,1fr);gap:16px;margin-top:16px;align-items:start} |
| 142 | 142 | @media (max-width:760px){.it-pane{grid-template-columns:minmax(0,1fr)}} |
| 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 |
||
| 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 | } | |
| 143 | 153 | `; |
| 144 | 154 | |
| 145 | 155 | /** |
| @@ -180,11 +190,15 @@ export const EDGES_JS = ` | ||
| 180 | 190 | * in this browser. `zoom(stage, box, grid, redraw)` returns { z, fit } where |
| 181 | 191 | * z() is the scale and fit() resizes the scrolled box to the scaled graph. |
| 182 | 192 | */ |
| 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. |
||
| 183 | 197 | export const ZOOM_JS = ` |
| 184 | 198 | 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'; | |
| 186 | 200 | 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; } | |
| 188 | 202 | 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. |
||
| 189 | 203 | function fit() { |
| 190 | 204 | grid.style.transform = Z === 1 ? '' : 'scale(' + Z + ')'; |
| @@ -192,8 +206,8 @@ export const ZOOM_JS = ` | ||
| 192 | 206 | if (val) val.textContent = Math.round(Z * 100) + '%'; |
| 193 | 207 | } |
| 194 | 208 | // 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)); | |
| 197 | 211 | if (z === Z) return; |
| 198 | 212 | if (px === undefined) { px = stage.clientWidth / 2; py = stage.clientHeight / 2; } |
| 199 | 213 | var gx = (stage.scrollLeft + px) / Z, gy = (stage.scrollTop + py) / Z; |
| @@ -216,7 +230,7 @@ export const ZOOM_JS = ` | ||
| 216 | 230 | // 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. |
| 217 | 231 | var tall = parseFloat(cs.maxHeight); if (!(tall > 0)) tall = window.innerHeight * 0.6; |
| 218 | 232 | 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); | |
| 220 | 234 | stage.scrollLeft = 0; stage.scrollTop = 0; |
| 221 | 235 | }); |
| 222 | 236 | stage.addEventListener('wheel', function (e) { |
test/iterations.test.ts +90 −1
| @@ -18,7 +18,9 @@ import { promote } from "./helpers/members.ts"; | ||
| 18 | 18 | import { serve, type Servers } from "../src/http/server.ts"; |
| 19 | 19 | import { familyRepos } from "../src/core/lineage.ts"; |
| 20 | 20 | 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"; | |
| 22 | 24 | |
| 23 | 25 | const build = (repo: string, label: string, parent: string | null = null, edge: IterationBuild["edge"] = parent === null ? null : "git"): IterationBuild => ({ |
| 24 | 26 | key: `${repo}:${label}`, |
| @@ -163,6 +165,93 @@ describe("iterations page", () => { | ||
| 163 | 165 | expect(columns).not.toContain('"lanes"'); |
| 164 | 166 | }); |
| 165 | 167 | |
| 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 | ||
| 166 | 255 | test("the markup the script relies on is there for a folded run", async () => { |
| 167 | 256 | const text = await (await get("/alice/game/iterations?layout=tree")).text(); |
| 168 | 257 | expect(text).toContain('data-lane="alice/game" data-repo="alice/game"'); |
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.