diff --git a/devlog/_plan/260829_gui_dashboard_slop/000_baseline_and_roadmap.md b/devlog/_plan/260829_gui_dashboard_slop/000_baseline_and_roadmap.md index 0d58135639..aaffa4e61e 100644 --- a/devlog/_plan/260829_gui_dashboard_slop/000_baseline_and_roadmap.md +++ b/devlog/_plan/260829_gui_dashboard_slop/000_baseline_and_roadmap.md @@ -121,4 +121,3 @@ distinct. `ssh lidge` + `ocx-run`; pushes use `--no-verify` only after those gates. - Delivery is a stacked PR chain onto `dev`, each PR carrying screenshots (`enforce-target` requires a screenshot for GUI PRs). - diff --git a/devlog/_plan/260829_gui_dashboard_slop/011_audit_correction_align_content.md b/devlog/_plan/260829_gui_dashboard_slop/011_audit_correction_align_content.md index 198ff217da..3c316777a6 100644 --- a/devlog/_plan/260829_gui_dashboard_slop/011_audit_correction_align_content.md +++ b/devlog/_plan/260829_gui_dashboard_slop/011_audit_correction_align_content.md @@ -112,4 +112,3 @@ flow (`position: fixed`, off-canvas at `x=-280`), so `.main-inner` JUMPS from 526px to 750px. The sidecar grid re-splits into two columns at 349px each and the 22.5px misalignment returns — on tablet widths, below the width where it was last believed fixed. Any fix must be verified at 760/740, not only at desktop widths. - diff --git a/devlog/_plan/260829_gui_dashboard_slop/020_phantom_grid_track.md b/devlog/_plan/260829_gui_dashboard_slop/020_phantom_grid_track.md index 63190005b4..6ead7444cb 100644 --- a/devlog/_plan/260829_gui_dashboard_slop/020_phantom_grid_track.md +++ b/devlog/_plan/260829_gui_dashboard_slop/020_phantom_grid_track.md @@ -1,5 +1,10 @@ # 020 — Phantom zero-width auto-fit track (wp2) +> **WITHDRAWN — nothing in this document ships.** The investigation concluded the +> reported defect is not a defect: a collapsed zero-width `auto-fit` track is +> normal behaviour, and no code change was made for it. Kept as the record of why +> the track is expected, so the next person who measures it does not re-open it. + ## Defect At vw ≥ 1440 both dashboard grids compute a third, zero-width column: diff --git a/devlog/_plan/260829_gui_dashboard_slop/030_dynamic_viewport_units.md b/devlog/_plan/260829_gui_dashboard_slop/030_dynamic_viewport_units.md index e7dfd01185..700e4d7b58 100644 --- a/devlog/_plan/260829_gui_dashboard_slop/030_dynamic_viewport_units.md +++ b/devlog/_plan/260829_gui_dashboard_slop/030_dynamic_viewport_units.md @@ -1,5 +1,10 @@ # 030 — Dynamic viewport units in scroll surfaces (wp3) +> **Implemented by this pull request**, separately from the sidecar alignment fix +> that the rest of this unit records. It is a different change to a different +> file (`gui/src/styles.css`), kept in the same unit because one audit pass found +> both. + ## Defect `gui/src/styles.css:2003`: diff --git a/gui/src/styles.css b/gui/src/styles.css index 222c0f1166..0ef2053fac 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -763,6 +763,16 @@ a.btn, a.btn:hover { text-decoration: none; } } /* Refresh icon spins while a sync is in flight (the `.spin` keyframes already exist). */ .spin-icon { animation: spin 0.9s linear infinite; } +/* The width cap needs two classes to beat `.notice { max-width: var(--prose-measure) }`, + which every toast also carries and which is declared LATER in this file. At equal + specificity source order won, so the toast resolved to 70ch (542px) rather than its + design width — visible as a toast wider than intended at desktop sizes. */ +.action-toast.notice { + /* Both halves of the original cap have to be restated here, not just the design width: + dropping the viewport term let the toast run to the screen edge at narrow widths + (measured left=0 at 430px, losing the 24px inset the right side keeps). */ + max-width: min(480px, calc(100vw - 48px)); +} /* Toast dismiss affordance: quiet ghost button inside the notice row. */ .action-toast-dismiss { flex: none; @@ -2000,7 +2010,12 @@ table.logs-table { .logs-table-wrap { overflow-y: auto; - max-height: calc(100vh - 260px); + /* `dvh`, not `vh`: static `vh` resolves against the LARGE viewport, so on mobile the + cap is computed for a viewport taller than the one the user can see and the last rows + sit under the browser chrome. The rest of the shell already moved to `100dvh` + (see .app, .sidebar, .main-inner--combos, and the mobile drawer), so this was the + outlier rather than the convention. */ + max-height: calc(100dvh - 260px); } .logs-table thead { diff --git a/gui/tests/viewport-scroll-caps.test.ts b/gui/tests/viewport-scroll-caps.test.ts new file mode 100644 index 0000000000..a8996cadf5 --- /dev/null +++ b/gui/tests/viewport-scroll-caps.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; + +/** + * Viewport-dependent sizing contracts in the shared stylesheet. + * + * Source-text assertions, not rendered measurements: happy-dom performs no layout, so a + * computed max-width here would prove nothing. Rendered proof was captured in a real + * browser via CDP while fixing these two rules; this file's job is to stop the specific + * shapes that caused the defects from coming back silently. + */ + +const cssUrl = new URL("../src/styles.css", import.meta.url); + +/** Strip comments so no assertion can pass on prose that quotes an old value. */ +function withoutComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, ""); +} + +/** All bodies for a selector, which may be declared more than once. */ +function allRuleBodies(css: string, selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const matches = [...css.matchAll(new RegExp("(^|\\n)\\s*" + escaped + "\\s*\\{([^}]*)\\}", "g"))]; + if (matches.length === 0) throw new Error("rule not found: " + selector); + return matches.map((m) => m[2]).join("\n"); +} + +test("the log table caps its scroll height against the dynamic viewport", async () => { + const css = withoutComments(await Bun.file(cssUrl).text()); + const wrap = allRuleBodies(css, ".logs-table-wrap"); + + // Static `vh` resolves against the LARGE viewport, which ignores mobile browser chrome: + // the cap is then computed for a viewport taller than the one the user can see and the + // last rows sit underneath the address bar. The rest of the shell (.app, .sidebar, + // .main-inner--combos, the mobile drawer) already uses 100dvh, so this rule was the + // outlier rather than the convention. + expect(wrap).toMatch(/max-height:\s*calc\(\s*100dvh\s*-/); + expect(wrap).not.toMatch(/max-height:\s*calc\(\s*100vh\s*-/); +}); + +test("the toast width cap outranks the later .notice rule", async () => { + const css = withoutComments(await Bun.file(cssUrl).text()); + + // Every toast carries BOTH classes, and `.notice { max-width: var(--prose-measure) }` + // (70ch = 542px) is declared later in this same file at equal specificity 0,1,0. Source + // order therefore won and a single-class `.action-toast` cap never applied - the toast + // rendered 542px instead of its design width. Two classes is what wins the cascade, so + // the cap must stay on the compound selector. + const compound = allRuleBodies(css, ".action-toast.notice"); + const cap = compound.match(/max-width:\s*min\(\s*([\d.]+)px\s*,\s*calc\(\s*100vw\s*-\s*([\d.]+)px\s*\)\s*\)/); + + // Both halves are asserted on purpose. An earlier revision kept only the design width, + // which dropped the viewport term and let the toast reach the screen edge at narrow + // widths (measured left = 0 at 430px, losing the 24px inset the right side keeps). + expect(cap).not.toBeNull(); + expect(Number(cap![1])).toBeGreaterThan(0); + expect(Number(cap![2])).toBeGreaterThan(0); + + // Guard the ordering premise itself: if `.notice` ever moved ABOVE this rule, a + // single-class cap would start working and someone could "simplify" the compound + // selector away. The assertion is only meaningful while `.notice` still comes later. + const noticeIndex = css.search(/(^|\n)\s*\.notice\s*\{/); + const compoundIndex = css.search(/(^|\n)\s*\.action-toast\.notice\s*\{/); + expect(compoundIndex).toBeGreaterThanOrEqual(0); + expect(noticeIndex).toBeGreaterThan(compoundIndex); +});