Files
civitai__civitai/test/geometry-setup.tsx
T
Zachary Lowden 2f3c556c7b feat(apps): the manage/table routes SPEND the ultrawide width instead of padding it (#4619)
* feat(apps): the manage/table routes SPEND the ultrawide width instead of padding it

PR #4615 raised APPS_PAGE_CONTAINER_WIDTH 1920 -> 2560, so every route in
APPS_FULL_MEASURE_PAGES went from 1888 to 2528 of content. Nothing was clipped and
nothing errored -- the extra 640px simply became PADDING, which on a space-between row
lands entirely between a row's content and the control that acts on it. The audit of
that PR measured the concrete instance: src/pages/apps/installed.tsx's four
`Group justify="space-between" wrap="nowrap"` rows each moved their button 640px further
from the name it belongs to. `/apps/review` had the same shape and had been "fixed" by
capping the whole page at 1368 -- refusing the width rather than spending it.

Scope is "wider columns only": proportional column widths with the primary column
absorbing the slack. No new fields, no new queries, no width-conditional column set.

New module `~/components/Apps/appsWideLayout`, two mechanisms:

  * AppsTableColgroup -- a percentage `<colgroup>` per table, with exactly ONE column
    left `auto` so automatic table layout hands it the surplus. Six ledgers, one per
    table SHAPE (the review queue's Deploy column and the revenue table's App column are
    both decided by DATA, never by width). Wired into UnifiedReviewList (/apps/review),
    MyAppsBody (/apps/mine), AppListingsModerationTable and RevenuePanel (/apps/revenue).
    Measured at 1440 vs 2560: /apps/review's App column takes 604.81 of the container's
    1120px surplus against 515.19 for the other four columns combined; against
    origin/main's components it took 202.27 against 917.72. /apps/mine: 616.00 against
    503.99, from 322.71 against 797.29.

  * AppsCardGrid -- /apps/installed's card lists become
    `repeat(auto-fill, minmax(min(100%, 1200px), 1fr))`. 1200 is picked so the ladder
    steps exactly where the surplus appeared: ONE column through the old 1920
    container's 1888 of content, TWO from 2416. So nothing a 1440 or 1920 monitor showed
    moves, and the name->Manage gap goes 1201.77 -> 1049.77 across the two fixture
    widths instead of 1201.77 -> 2321.77.

Measures become BANDS. A fixed measure did not grow with the container, so /apps/submit
rendered the same 1068px of form on a 1440 monitor and on a 2560 one. APPS_READABLE_MEASURE
is now {min: 1068, max: 1368, grow: 55} and APPS_TWO_COLUMN_DETAIL_MEASURE
{min: 1288, max: 1600, grow: 65}; AppsPageLayout renders them through appsMeasureCss as a
clamp() whose middle term is a PERCENTAGE, so the ramp is bounded by the container's own
cap. Each band's floor is the width that route already rendered at, and `grow` is chosen
so the band is still AT its floor at 1888 of content and AT its ceiling by 2528 -- both
asserted as arithmetic, not described in prose. The two-column ceiling is derived rather
than picked: 8/12 x 1600 = 1066.67 <= the readable floor, so the store preview's markdown
column stays inside the measure the readable class exists to hold.

APPS_NARROW_TABLE_MEASURE is DELETED and /apps/review joins APPS_FULL_MEASURE_PAGES. The
constant is asserted absent rather than merely unused -- a surviving export with no
consumer is the shape that gets wired back in by the next reader who finds a table
reading too wide.

Tests
  * unit: __tests__/appsWideLayout.test.ts (new) pins the rule on inputs it must REJECT,
    every ledger's column count and primary position, and that each ledger is actually
    WIRED to its table. __tests__/appsPageWidths.test.ts gains the band arithmetic and the
    card ladder; the taxonomy, gutter and /apps/mine scroll-floor pins are updated for the
    band shape rather than dropped.
  * geometry: AppsWideLayout.geometry.test.tsx (new) measures rendered boxes with
    getBoundingClientRect at TWO named container widths (1440 -> 1408 of content,
    2560 -> 2528), for two table routes plus the installed card row. It is in the
    `geometry` project because every number depends on Mantine's Container cap, Table's
    width and the cascade LAYER ORDER, none of which the `component` tier loads.

Two comments were corrected because measurement refuted them. "A browser ignores a
misplaced <colgroup>" is FALSE here: moving the element after <Table.Tbody> changed no
rendered width at all (React inserts via the DOM API, so the parser's table
foster-parenting never runs). The ordering is a validity rule enforced structurally, and
the geometry file says so. The geometry file also states what it does NOT prove: it
supplies the card grid itself, so it cannot see /apps/installed reverting to a Stack --
that claim is the unit wiring guard's.

* docs(test): the geometry tier HAS a CI job now — the harness said it had none

`.github/workflows/lint.yml` gained a `geometry:` job (`Geometry tests`) that installs
Chromium, runs `vitest run --project geometry` and asserts a collected-count floor, but
`test/geometry-setup.tsx` still opened with "WHAT RUNS THIS, TODAY: NOTHING. SAY IT OUT
LOUD." That is the rot the paragraph was written to prevent, arriving from the other
direction: it would talk the next reader out of relying on a gate that exists. Observed
on this PR - the check is in the list.

Replaced with what is actually true, including the half that is easy to overstate: the
job carries `continue-on-error: ${{ github.event_name == 'pull_request' }}`, so it REPORTS
on a PR and BLOCKS on a push to `main`. `component` really does remain ungated.

* style(apps): prettier on the two files this PR made unformatted

`ESLint + Prettier (changed files)` went red on the ADDED-file half, which is the
blocking half. Two files, one reason each:

  * `__tests__/appsWideLayout.test.ts` is ADDED, so the gate is real; one over-long
    regex property needed wrapping.
  * `__tests__/appsPageWidths.test.ts` is MODIFIED (report-only) but was prettier-CLEAN
    at origin/main, so this PR is what made it dirty. Fixed rather than shrugged off.

`UnifiedReviewList.tsx` is deliberately NOT reformatted: it was already prettier-dirty at
origin/main (verified by checking out that revision at the repo path, not from /tmp --
prettier resolves its config from the file path, so a copy outside the repo is graded
under a DIFFERENT config and reported every single-quoted import as unformatted). The
workflow makes modified files report-only for exactly this reason; reformatting it here
would be a whole-file reformat unrelated to the change.

* ci: retrigger against the fixed shard gate (empty)

* fix(apps): the uncap exposed FIVE tables, not two — enumerate them instead of remembering

Round-1 audit payload (F1). Removing `/apps/review`'s 1368 body cap is a claim about
EVERY table on that route, and the first pass ledgered the two somebody had in mind. The
cap was the only thing holding the others down. Measured on `ActivePreviewsPanel` in the
real layout, 1440 -> 2560, with no ledger:

  columns  228.02 | 165.17 | 146.45 | 152.05 | 682.31
           413.89 | 299.83 | 265.84 | 276.00 | 1238.44
  slug -> "Tear down" gap   609.67 -> 1173.55   (+563.88)

i.e. the change re-opened, on that table, exactly the defect it exists to remove. Five
tables were exposed once the route set is walked rather than recalled: ActivePreviewsPanel
and OffsiteReportsQueue (/apps/review), AppActivityPanel (/apps/installed) and ReportTabs
(/apps/review/<id>, via OnsiteReviewModalBody -> AgentReviewPanel). All four now carry
ledgers; MySubmissionsList, OffsiteSubmissionsList and the dead OffsiteReviewQueue table
are EXEMPT, and the exemption is re-derived by searching src for a render of each.

THE PRIMARY-COLUMN RULE HAS TWO CASES NOW, because applying the general one to
ActivePreviewsPanel made the defect WORSE. Its App cell is a short `<Code>{slug}</Code>`,
so making it primary put 49% of the table between the slug and the button. Written down:

  (a) a column with genuinely variable long content is primary (slack becomes headroom);
  (b) a table where NO column has that takes its ACTION column instead, so the slack lands
      past the button rather than in front of it.

And the fixed shares are sized to CONTENT, not spread to fill — a share larger than its
cell needs is padding relabelled. For a case-(b) table they are deliberately below
min-content (the shrink-to-content idiom), which is what makes those columns constant:
content-sized shares (9/7/6/6) still moved the gap 510.38 -> 823.95; 3/2/2/2 holds it at
446.97 -> 446.97, with the action column taking 1120 of 1120 and the data columns 0.

Every ledger re-tuned on the same reasoning.

GUARDS. The hand-written four-file list is replaced by an AST walk over every `.tsx` under
`src/components/{Apps,AppBlocks}`: each `<Table>` with a header row must carry a colgroup
as its own FIRST CHILD or be exempted by name, and its ledger length(s) must equal the
header cells the table actually renders. That one guard closes four findings at once:

  · F1  coverage is derived, so a table nobody mentioned cannot be missed.
  · F4  "each ledger has the column COUNT its table renders" was a relationship in the
        title and a literal in the body. Adding a `<Table.Th>` without touching the ledger
        was green at BOTH tiers for AppListingsModerationTable and RevenuePanel, and
        `[null,5,5,5,5]` passed everything. The table is read now.
  · the structural guard was SPELLED: `indexOf('<AppsTableColgroup')` vs
        `indexOf('<Table.Thead')` passes a colgroup hoisted OUTSIDE the `<Table>` (equally
        inert), and reads the first occurrence in the FILE, so the second table in
        OffsiteReviewQueue.tsx was graded against the first one's colgroup.
  · the set is a LEDGER (11 entries, two of them in one file), so it fails when it grows
        or shrinks rather than only when it empties.

F2 — `/apps/installed`'s adoption guard matched `/<AppsCardGrid\b/` over the whole file,
and this PR added two OTHER call sites to that file, so it stopped seeing the case it
exists for: reverting ONLY the installed-apps list to `<Stack gap="md">` passed unit
124/124 and geometry 27/27. Now keyed on each list's own testId.

F3 — the retracted "a browser ignores a misplaced colgroup" belief was still attached to
the guard it justifies, in the two places a reader lands. Both rewritten; the geometry
file now states the measured split (placement + column count are the unit tier's, widths
are its own) instead of claiming the unit tier sees neither.

F5 — `min(100%, N)` was documented load-bearing and unguarded: dropping it survived both
tiers because neither fixture is under 1200px. At 390x844 the real behaviour is worse than
the docstring said — gridBox 358, child 1200, `document.scrollWidth` unchanged, i.e.
CLIPPED with no scrollbar rather than "overflows horizontally". Corrected, and pinned at a
phone viewport with a positive control that the fixture can see the defect.

F6 — `APPS_TWO_COLUMN_DETAIL_MEASURE` is a band reaching 1600, so PageBlockHost's
"1600 is above every ordinary content measure" is parity, not headroom. The conclusion
survives (no app is narrower than the page that launched it); the margin does not. Both
the constant's doc and its guard say so.

F7 — three space-between rows, not four: `ScopeGrantsPanel`'s has a single flex child and
no control, so nothing moved there. Named by function rather than line number, since this
PR's own edits moved every line first quoted.

F8 — `AppsCardGrid` takes a `gap`. The Hidden tab was `<Stack gap="sm">` (12px) and
defaulting it to 16 would have moved something a 1440 monitor shows, against this module's
own "nothing changes" claim. Both gaps give the same ladder at both container widths.

* fix(apps): two primary columns that could not use the width — measured, not reasoned

Round-2 audit payload. Both ledgers added last round picked a primary column on what the
FIELD MEANS rather than on what the CELL CAN DO, and rendering them settled it.

F-B `OffsiteReportsQueue`. `Reason` was primary because it is "operator-written free
text". It is `lineClamp={2} style={{ maxWidth: 260 }}`. Measured 1440 -> 2560:

  columns  140.59 | 587.73  | 90.36  | 98.41  | 74.8  | 414.11
           252.59 | 1404.64 | 151.55 | 176.81 | 126.3 | 414.11
  details box  260 -> 260

The primary column took +816.91 and the sentence gained nothing — "padding relabelled",
which this module forbids two paragraphs above the ledger. `App` was the earlier guess and
is a genuine case-(a) candidate (its uncapped listing NAME grows, glyph 98.05 -> 199.45),
just far too small to absorb ~1350px. When no cell can, it is case (b): the actions column
takes the slack. Measured after: Status -> first button 32 -> 32.

That required flipping the action `Group` from `justify="flex-end"` to `flex-start`. It is
a no-op wherever that column sits at min-content (414.11 at BOTH widths before the ledger),
i.e. everywhere it is not primary — and without it case (b) IS the defect: measured with
`flex-end` restored, the same gap goes 329.44 -> 1093.69.

F-C `AppActivityPanel`. `Detail` was primary; the component's own comment at the render
site says the ACTION cell carries the human sentence and DETAIL always holds the raw
technical ref. Measured on a rich `tip` row:

  Action glyph  "Tipped 500 Buzz to user #4242"  102.97 -> 166.34   (was being WRAPPED)
  Detail glyph  "POST /api/v1/buzz/tip"          151.72 -> 151.72   (fixed token)

`Detail` took +772.78 for a token that cannot use a pixel of it. The fix is NOT "make
Action primary" — the sentence is variable but BOUNDED, so ~1800px would park 1500px of
dead space mid-row. Action gets a generous fixed share and the LAST column trails.

Both re-tunes were then measured AGAIN and both had squeezed a cell at 1440 — the failure
this module's provenance rule exists to prevent, introduced by the fix for the previous
one. `Reason` at 12% rendered a 136.72 details box at 1408 instead of 260; `Detail` at 7%
wrapped its monospace ref (glyph 115.59 at 1408 vs 151.72 at 2528). Both shares are now set
from the NARROW end (21% / 13%). The residue is ~239px inside `Reason` at 2528, which is
the honest cost of a hard-capped cell in a fluid table and smaller than any alternative
measured.

Case (b) is generalised in the module: it is the LAST column (usually the action column),
and it only works if that cell is left-aligned.

GUARDS

F-D the primary-position pin had gone 6/6 -> 6/10 when four ledgers were added, and that
gap is what let F-B and F-C ship: measured, moving the primary in all four new ledgers left
the unit tier 31/31 green and geometry caught only `ActivePreviewsPanel`. It is keyed off
`APPS_TABLE_COLUMN_LEDGERS` now, so a ledger cannot be added without a decision.

Both re-decided tables gained geometry arms, which is also the coverage gap round 2 listed
as "not verified".

F-E the F8 gap equivalence was cited as "pinned" and "asserted" and was neither — every
`appsCardGridColumnsAt` call site used the default gap, and deleting `gap={12}` from the
Hidden tab left the tier 31/31 green. Now asserted at both gaps with a guard-the-guard that
a ladder-changing gap DOES disagree, plus the call site, plus a browser-side reading of the
resolved `column-gap`. The `data-apps-card-grid-gap` attribute had zero consumers and is
removed rather than left as decoration.

F-A the no-ledger baseline quoted in five places (including a live assertion message) was
`609.67 -> 1173.55`, which is the `<Code>` border box to the row's FIRST control. The
shipped helper measures the slug's GLYPH RANGE to the "Tear down" BUTTON: `817.36 ->
1381.23`. The delta is +563.87 either way so the argument is untouched, but a message that
quoted one pair while printing the other reads as a broken harness.

F-F the guard's non-coverage list was written as closed ("stated rather than implied") and
was not — a NEW file can still escape by import-aliasing `Table`, or by extracting its
header row into a sibling component. Both are stated; neither is fixed, because widening
the walk to resolve aliases and cross-component structure is a parser, and this repo's
`--header-height` guard records five rounds in which each parser added to close a hole
shipped a new false PASS. Existing tables cannot escape either way — the SITES ledger is an
exact list and reds on a shrink.

Also: the "all eight geometry assertions" figure is de-quoted in three places (the file has
grown twice since), and the mutation it describes was re-confirmed at the new size.

* fix(apps): give the tier HEIGHT and a narrow width — then two ledgers had to go

Round-3 audit payload. Round 2 picked a wrong primary; round 3 fixed the primary and
squeezed the columns. Each time the guard could not see the next failure, because every
geometry arm read a WIDTH at 1440/2560 only — and a column squeezed below its content does
not get narrower than a width assertion expects, it gets TALLER.

THE GUARD FIRST, because it is what stops a round 5.

  · two more viewports, 768 and 1200 — a percentage share is smallest in absolute px at the
    narrow end, which is where a share sized from a 1408 measurement bites;
  · row HEIGHT and LINE COUNT, not just width;
  · and the invariant is an A/B against the SAME TREE WITH ITS `<colgroup>` DETACHED, at
    each of the four widths. Not against a literal (it would rot on any copy change) and
    not against the other widths (rows legitimately wrap more at 768 for ANY table, so
    "no taller than at 2560" is a claim no correct table could satisfy). What a ledger must
    never do is make a row taller than the browser's own layout at THAT width.

It found three more instances of the class the moment it was written, in ledgers this PR
shipped in rounds 2 and 3. That is the guard working, not a new defect.

WHAT THE MEASUREMENTS THEN FORCED

`AppActivityPanel` — row height 36.19 natural, 48.09 under round 2's ledger, 64.89 under
round 3's, at 768/1200/1440. `When` broke a `YYYY-MM-DD HH:mm` stamp across THREE lines and
`App` sat pinned at its 108.52 min-content from 768 through 2560, so a long name was
ellipsised identically at both ends. NO LEDGER FIXES IT: this table's max-content sum
(~735px) is the container's content width AT 768, so there is no surplus to place at the
narrow end. Three candidates sized from 1200 still wrapped at 768; the one configuration
that holds a single line everywhere — [16, 12, 27, 25, null] — reproduces natural layout at
2560 to within ~15px on three of five columns. The ledger is DELETED and the table is
EXEMPT.

`OffsiteReportsQueue` — same conclusion, three wrong ledgers later. At 1200 its row wants
App 240 + Reason 292 + Reporter 94 + Reported 133 + Status 86 + actions 414 = 1259px in
1168px, so something is under-served whatever the split. Every candidate was taller than
natural at 1200 (105.48 / 177.88 against 88.69) or clipped the `lineClamp={2}` details
harder than natural (a 150.77px details box against 260) — and the second is INVISIBLE to a
row-height check, which is why the tier now A/Bs WIDTH as well. Ledger deleted, table
EXEMPT, and its `justify="flex-end"` restored: that flip was correct only as part of case
(b), and with no ledger the column sits at min-content where both alignments render
identically.

`ActivePreviewsPanel` and `UnifiedReviewList` KEEP their ledgers — their regressions were
fixable. Both were the same root cause: a share deliberately below min-content only holds
one line when min-content is the WHOLE label. `white-space: nowrap` on the state badge, the
relative-age label, the kind badge and the submitted-at stamp makes that true, and all four
are labels that should never have wrapped. Previews +3.59 → 0 at every width; the queue
+12.2 → 0 at 768/1200/1440.

EXEMPTIONS NOW HAVE TWO KINDS, EACH VERIFIED

`unrendered` is re-derived by searching src for a JSX render, as before. `no-surplus` is a
table that IS rendered, so that check would reject it; what it must carry instead is a NAMED
geometry arm that keeps taking the measurement the exemption rests on. Deleting the arm
turns the exemption red rather than leaving it an unmeasured claim. Both branches are
exercised, and the guard asserts so.

CORRECTIONS from the round-3 audit

F3 — "the flip is a no-op everywhere it is not primary" was true only under the previous
ledger; with NO colgroup the actions column measures 414.16 at 1440 and 728.39 at 2560. The
sentence is gone with the flip it justified.

F4 — the App-rejection figure (glyph 98.05 → 199.45) was measured with App held at 10%;
given room the same fixture reaches 331.3. That paragraph is gone with the ledger.

Plus the two grammar breaks from de-quoting the geometry count.
2026-09-04 20:14:35 -05:00

485 lines
24 KiB
TypeScript

/**
* GEOMETRY HARNESS — the `geometry` Vitest project's setup file.
*
* A second browser-mode project that renders against the REAL app cascade at an
* EXPLICIT viewport, so a test can assert PIXELS rather than attributes.
*
* ─────────────────────────────────────────────────────────────────────────────
* WHAT THE GAP ACTUALLY IS — AND WHAT IT IS NOT
* ─────────────────────────────────────────────────────────────────────────────
* 🔴 THE `component` PROJECT IS A REAL BROWSER WITH A REAL LAYOUT ENGINE. It runs
* headless Chromium through `@vitest/browser-playwright`, `page.viewport()`
* genuinely moves `window.innerWidth/Height` there, and `getBoundingClientRect()`
* returns real non-zero boxes. Anyone arriving here expecting "the component tier
* is jsdom, so it cannot lay out" should stop: that is false for this repo, and
* a harness justified on it would be solving a problem that does not exist.
*
* What that tier is missing is the STYLESHEET and the VIEWPORT:
*
* · `test/component-setup.tsx` parses the `:root` custom properties out of
* `globals.css` and injects ONLY those — measured, the document holds 24 CSS
* rules. So every Mantine class is styleless, every Tailwind utility is inert
* (`className="flex"` computes `display: block`), and a `var(--mantine-*)`
* written by a stylesheet is simply absent. A real layout engine with no
* stylesheet still cannot measure the real layout: it measures a DIFFERENT,
* internally-consistent one, which is worse than measuring nothing.
* · nothing sets a viewport, so every file inherits the runner's silent default.
*
* The consequence is the same either way and it is the thing to hold on to: a
* `getComputedStyle` assertion whose expected value happens to be the CSS INITIAL
* value (`nowrap`, `visible`, `static`, `auto`, `none`, `0px`) passes against a
* broken component, because an unstyled element reports exactly those. And the
* layout DECISION is testable without any of this — `data-layout="stacked"` is an
* attribute — which is the trap: a suite can pin every decision correctly and
* still ship the wrong sizing.
*
* ─────────────────────────────────────────────────────────────────────────────
* WHY A SECOND PROJECT, GIVEN THE CHEAPER OPTIONS
* ─────────────────────────────────────────────────────────────────────────────
* Two cheaper options exist and both were weighed:
*
* (a) LOAD THE CASCADE IN THE SHARED SETUP. Rejected. `component-setup.tsx`'s own
* header records that importing the real cascade changes the rendered geometry
* of existing tests, and that is not a guess — measured here, the SAME chrome
* bar is 200px with the shared setup and 31px with the cascade loaded, a 169px
* move on one element. 212 files / 2,362 tests run in that tier, 14 of them
* reading `getBoundingClientRect` and 20 reading `getComputedStyle`. Changing
* the cascade under all of them is a suite-wide rewrite, not a fix.
*
* (b) IMPORT THE STYLESHEET PER FILE. This already exists — 15 of the 212 browser
* test files import a Mantine stylesheet themselves and 3 also import
* `~/styles/globals.css` — and it stays available; nothing here deprecates it.
* What it does not give you is a GUARANTEE. Each of those files chose its own
* subset (7 take unlayered `@mantine/core/styles.css`, 2 take the layered
* variant the app actually ships), none declares the `@layer` ORDER that
* `_document.tsx` puts first in `<head>`, none sets a default viewport, and
* the "did my stylesheet actually load" guard has been re-hand-rolled per file
* (`assertLayoutIsReal` in `AppListingCard.browser.test.tsx` is one copy).
* A project makes the cascade, the viewport and the controls properties of the
* TIER rather than of whoever remembered.
*
* So the cascade lands in a NEW project with a NEW glob, and nothing that runs
* today changes — verified: the full `component` tier is 212 files / 2,362 tests
* passed, before and after. Vitest browser mode gives each test FILE its own
* iframe, so the two setups cannot leak into one another even when both run.
*
* 🔴 THE SAME FIXTURE, THE SAME CORRECT SOURCE, MEASURED IN BOTH TIERS
* (`PageBlockHost` in its production shell chain, 2026-09-03):
*
* `component` `geometry`
* viewport 414 x 896 390 x 844 (default vs set)
* CSS rules in the document 24 3,677
* box-sizing on a bare div content-box border-box
* `className="flex"` block flex
* chrome bar height 200 31
* host frame height 350 844
* APP COLUMN HEIGHT 150 813
*
* That last row is the whole argument. `150` is ALSO what the app column
* measures in this harness when the recorded `flex: 1` defect is planted — so an
* assertion written in the `component` tier would have to expect the number the
* DEFECT produces, and could not tell the two apart at all.
*
* ─────────────────────────────────────────────────────────────────────────────
* THE VIEWPORT IS SET AND THEN OBSERVED
* ─────────────────────────────────────────────────────────────────────────────
* The runner's default is **414 x 896** (measured 2026-09-03 against this repo's
* pinned Vitest 4.1.11 / Playwright provider, `devicePixelRatio: 1`). Note that
* it is a DEFAULT, not "unset": a suite that never calls `page.viewport()` is
* measuring 414px and does not say so, and the number is a property of the
* runner rather than of anything in this repo — a Vitest or Playwright bump can
* move it and no assertion would notice.
*
* `renderAtViewport` therefore SETS the viewport and then THROWS unless the
* window reports back the size it asked for. Tests are still expected to assert
* `observed` against a literal of their own — trusting a config is what produced
* the vacuous pass this harness exists to remove.
*
* ─────────────────────────────────────────────────────────────────────────────
* 🔴 WHAT RUNS THIS: THE `Geometry tests` JOB — AND IT IS REPORT-ONLY ON A PR.
* ─────────────────────────────────────────────────────────────────────────────
* ⚠️ THIS PARAGRAPH SAID "NOTHING RUNS THIS, SAY IT OUT LOUD" AND HAD STOPPED BEING
* TRUE. `.github/workflows/lint.yml` now carries a `geometry:` job (`Geometry tests`)
* that installs Chromium and runs `vitest run --project geometry`, plus a collected-
* count ledger so a selector that matches nothing fails instead of exiting 0. It is
* observable on any PR's check list. Left uncorrected, the sentence would have talked
* the next reader out of relying on a gate that exists — the mirror image of the rot it
* was written to prevent.
*
* What is still true, and is the part that matters when you read a green check:
* the job carries `continue-on-error: ${{ github.event_name == 'pull_request' }}`, so
* on a PR it REPORTS and on a push to `main` it BLOCKS. Do not write "the blocking
* tier" about this project without that qualifier.
*
* `component` is still ungated: no project selector in that workflow matches it, and
* its only CI home is the preview pipeline's report-only `preview / component-tests`.
*
* `pnpm run test:geometry` is still the whole command locally, and the glob is
* disjoint from every other project's, so adding a file here cannot change what any
* other job runs.
*/
// ── THE CASCADE, IN PRODUCTION ORDER ─────────────────────────────────────────
// Mirrors `src/pages/_document.tsx` (the layer-order declaration) followed by
// the stylesheet imports at the top of `src/pages/_app.tsx`, in that file's
// order. Import order IS the contract here — see `cascade-layer-order.css`.
import './cascade-layer-order.css';
import '~/styles/globals.css';
import '@mantine/core/styles.layer.css';
import '@mantine/dates/styles.layer.css';
import '@mantine/dropzone/styles.layer.css';
import '@mantine/notifications/styles.layer.css';
import '@mantine/nprogress/styles.layer.css';
import '@mantine/tiptap/styles.layer.css';
import 'mantine-react-table/styles.css';
import React from 'react';
import { afterEach, vi } from 'vitest';
import { page } from 'vitest/browser';
import { render, cleanup } from 'vitest-browser-react';
import { MantineProvider } from '@mantine/core';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
/**
* A phone. 390x844 is the iPhone 12/13/14/15 portrait logical viewport and the
* modal phone width in this app's own RUM.
*
* 🔴 IT IS DELIBERATELY NOT THE RUNNER'S 414x896 DEFAULT. Two reasons, and the
* second is the point of the harness. 414 sits ABOVE the 390/393 band most
* phones report, so a `min-width` breakpoint or a flex floor that misbehaves
* between 360 and 400 is invisible at the default; and a number the harness
* merely inherited cannot be asserted against, because there is nothing to
* compare it to that would not move with it.
*/
export const PHONE_VIEWPORT = { width: 390, height: 844 } as const;
/** A narrow phone — the low end of the band, for a second measurement point. */
export const NARROW_PHONE_VIEWPORT = { width: 360, height: 780 } as const;
export type Viewport = { readonly width: number; readonly height: number };
// ─────────────────────────────────────────────────────────────────────────────
// Environment stubs.
//
// 🔴 DELIBERATELY A COPY OF `component-setup.tsx`'s STUBS, NOT AN IMPORT OF IT.
// Importing that module would re-run its `:root` extraction and inject a SECOND
// `:root` block on top of the real cascade — the one thing this harness exists
// to avoid. Factoring the stubs into a third shared module was rejected because
// `vi.mock` is hoisted PER MODULE by the Vitest transform, so moving the call
// changes when it registers; that is a behaviour change to the 484-test tier
// bought for a few lines of de-duplication. The two setups are expected to
// diverge on the cascade and to agree on the stubs; if you change one stub here,
// change it there.
// ─────────────────────────────────────────────────────────────────────────────
vi.mock('next/router', () => {
const router = {
push: vi.fn().mockResolvedValue(true),
replace: vi.fn().mockResolvedValue(true),
prefetch: vi.fn().mockResolvedValue(undefined),
back: vi.fn(),
forward: vi.fn(),
reload: vi.fn(),
beforePopState: vi.fn(),
query: {},
pathname: '/',
asPath: '/',
route: '/',
basePath: '',
isReady: true,
isFallback: false,
isPreview: false,
events: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
};
return {
__esModule: true,
useRouter: () => router,
Router: router,
default: router,
withRouter: (Component: React.ComponentType) => Component,
};
});
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn().mockResolvedValue(undefined),
readText: vi.fn().mockResolvedValue(''),
},
});
// `cleanup()` is ASYNC and the hook MUST await it — the same container-race that
// `component-setup.tsx` documents at length applies here unchanged.
afterEach(async () => {
await cleanup();
});
function Providers({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
return (
<QueryClientProvider client={queryClient}>
<MantineProvider>{children}</MantineProvider>
</QueryClientProvider>
);
}
/** Two frames, so style application and layout have both settled. */
export function nextLayout(): Promise<void> {
return new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(() => res())));
}
/** What the WINDOW says its size is — never what the config asked for. */
export function observedViewport(): { width: number; height: number } {
return { width: window.innerWidth, height: window.innerHeight };
}
// ─────────────────────────────────────────────────────────────────────────────
// POSITIVE CONTROLS
//
// A harness wired to nothing reports a clean zero that is indistinguishable from
// a pass, so every claim this file makes about "the real stylesheet" has to be a
// non-zero COUNT or a resolved VALUE that could not exist without it.
// ─────────────────────────────────────────────────────────────────────────────
/** Every rule in the document, at-rules descended into. */
export function loadedCssRuleCount(): number {
let n = 0;
const walk = (rules: CSSRuleList) => {
for (const rule of Array.from(rules)) {
n += 1;
const nested = (rule as CSSGroupingRule).cssRules;
if (nested) walk(nested);
}
};
for (const sheet of Array.from(document.styleSheets)) {
// A cross-origin sheet throws on `.cssRules`; none is expected here, and a
// silent skip would understate the count, so let it throw rather than hide.
walk(sheet.cssRules);
}
return n;
}
/**
* Evidence, per source, that the cascade this harness claims to load is LOADED
* AND APPLIED — each entry a value that is impossible without the stylesheet it
* names, measured off a real element rather than read out of the CSSOM.
*
* Returned rather than asserted so a test states its own expectations; a helper
* that both measures and judges is one nobody can watch fail.
*/
/**
* The cascade-layer ORDER statement, and whether anything registered a layer
* before it.
*
* 🔴 THE INVARIANT IS "NOTHING NAMED A LAYER FIRST", NOT "IT IS RULE ZERO".
* A layer's priority is fixed at the first appearance of its NAME, so what has
* to hold is that no `@layer <name> { … }` block is parsed before the `@layer a,
* b, c;` statement. Asserting `document.styleSheets[0].cssRules[0]` instead
* looked equivalent and is not: the runner injects sheets of its own, so that
* check went red while the real invariant held — a control failing for a reason
* that had nothing to do with what it claims to protect.
*/
export function layerOrderEvidence(): {
/** The layer names in the `@layer a, b, c;` statement, or null if there is none. */
declaredOrder: string[] | null;
/** True when no `@layer <name> { … }` block precedes that statement. */
declaredBeforeAnyLayerBlock: boolean;
} {
let declaredOrder: string[] | null = null;
let sawLayerBlockFirst = false;
const haveStatement = typeof CSSLayerStatementRule !== 'undefined';
const haveBlock = typeof CSSLayerBlockRule !== 'undefined';
outer: for (const sheet of Array.from(document.styleSheets)) {
for (const rule of Array.from(sheet.cssRules)) {
if (haveStatement && rule instanceof CSSLayerStatementRule) {
declaredOrder = Array.from(rule.nameList);
break outer;
}
if (haveBlock && rule instanceof CSSLayerBlockRule) {
sawLayerBlockFirst = true;
break outer;
}
}
}
return {
declaredOrder,
declaredBeforeAnyLayerBlock: declaredOrder !== null && !sawLayerBlockFirst,
};
}
/**
* Evidence that the cascade is loaded AND applied.
*
* 🔴 EVERY FIELD HERE HAS BEEN MEASURED IN BOTH TIERS AND KEPT ONLY IF IT
* DISAGREES. A control that reports the same value with and without the thing it
* is checking attributes nothing, and reads as a confirmation. The body's
* `margin-top` was in this list and was CUT for exactly that reason: measured
* 2026-09-03, it is `0px` in the `component` tier too — which loads 24 CSS rules
* and no preflight at all — so it was evidence of nothing. What survived, with
* both readings (`component` → `geometry`):
*
* ruleCount 24 → 3,677
* probeBoxSizing content-box → border-box
* tailwindFlexUtility block → flex
*/
export function cascadeEvidence(): {
ruleCount: number;
layerOrder: ReturnType<typeof layerOrderEvidence>;
/** Preflight's `*, ::before, ::after { box-sizing: border-box }`; UA default is `content-box`. */
probeBoxSizing: string;
/** `@tailwind utilities` — inert in the `component` tier, which loads none of it. */
tailwindFlexUtilityResolves: boolean;
/** A `theme`-layer rule from globals.css. */
htmlFontSize: string;
} {
const probe = document.createElement('div');
probe.className = 'flex';
document.body.appendChild(probe);
const probeStyle = getComputedStyle(probe);
const evidence = {
ruleCount: loadedCssRuleCount(),
layerOrder: layerOrderEvidence(),
probeBoxSizing: probeStyle.boxSizing,
tailwindFlexUtilityResolves: probeStyle.display === 'flex',
htmlFontSize: getComputedStyle(document.documentElement).fontSize,
};
probe.remove();
return evidence;
}
// ─────────────────────────────────────────────────────────────────────────────
// MEASUREMENT
// ─────────────────────────────────────────────────────────────────────────────
/** A rounded border box. 2dp, so sub-pixel layout is visible but noise is not. */
export function box(el: Element): {
top: number;
right: number;
bottom: number;
left: number;
width: number;
height: number;
} {
const r = el.getBoundingClientRect();
const q = (n: number) => Math.round(n * 100) / 100;
return {
top: q(r.top),
right: q(r.right),
bottom: q(r.bottom),
left: q(r.left),
width: q(r.width),
height: q(r.height),
};
}
/**
* The union of an element's CHILD boxes — "how much box the content actually
* needs", against which the parent's own box can be compared.
*
* 🔴 `scrollHeight` is NOT this and does not answer the same question: it is
* clamped to the padding box, so a parent that is far TALLER than its content
* reports its own height and the overshoot is invisible. That is precisely the
* defect shape here — a 220px box around 53px of content — so the union is
* computed from the children's own rects.
*
* Returns `null` for an element with no element children.
*/
export function childrenUnionBox(el: Element): ReturnType<typeof box> | null {
const kids = Array.from(el.children);
if (kids.length === 0) return null;
const rects = kids.map((k) => k.getBoundingClientRect());
const q = (n: number) => Math.round(n * 100) / 100;
const top = Math.min(...rects.map((r) => r.top));
const left = Math.min(...rects.map((r) => r.left));
const bottom = Math.max(...rects.map((r) => r.bottom));
const right = Math.max(...rects.map((r) => r.right));
return {
top: q(top),
left: q(left),
bottom: q(bottom),
right: q(right),
width: q(right - left),
height: q(bottom - top),
};
}
/**
* A RESOLVED LONGHAND, by CSS property name.
*
* Read longhands, never the `flex` shorthand: `getComputedStyle(el).flex` is a
* serialisation, so `flex: 1` and `flex: 1 1 0%` are the same string and a
* `flex-basis` regression can hide inside it. `longhand(el, 'flex-basis')`
* returns `220px` or `0%` and cannot.
*/
export function longhand(el: Element, property: string): string {
return getComputedStyle(el).getPropertyValue(property).trim();
}
/** The three flex longhands together — the shape a `flex` shorthand bug lives in. */
export function flexLonghands(el: Element): { grow: string; shrink: string; basis: string } {
return {
grow: longhand(el, 'flex-grow'),
shrink: longhand(el, 'flex-shrink'),
basis: longhand(el, 'flex-basis'),
};
}
/**
* The axis a flex CONTAINER lays its children out on — the fact that decides
* whether `flex-basis` is a width or a height, and the one nothing in the
* attribute-level tier can see.
*/
export function flexAxis(el: Element): 'row' | 'column' | 'none' {
const s = getComputedStyle(el);
if (s.display !== 'flex' && s.display !== 'inline-flex') return 'none';
return s.flexDirection.startsWith('column') ? 'column' : 'row';
}
/**
* Render at an EXPLICIT viewport and hand back what the window actually reports.
*
* Throws rather than warns on a mismatch: a viewport call that silently did
* nothing turns every geometry assertion in the file into a claim about a
* different screen, and that is the failure mode this project exists to remove.
* The throw is the harness's own floor — tests are still expected to assert
* `observed` against their own literal.
*/
export async function renderAtViewport(
ui: React.ReactElement,
viewport: Viewport = PHONE_VIEWPORT
): Promise<{ result: ReturnType<typeof render>; observed: { width: number; height: number } }> {
await page.viewport(viewport.width, viewport.height);
const result = render(ui, { wrapper: Providers });
await nextLayout();
const observed = observedViewport();
if (observed.width !== viewport.width || observed.height !== viewport.height) {
throw new Error(
`geometry harness: asked for a ${viewport.width}x${viewport.height} viewport but the window ` +
`reports ${observed.width}x${observed.height}. Every measurement in this file would be ` +
'about a screen nobody chose.'
);
}
// 🔴 A SEPARATE FIELD, NOT A PROPERTY BOLTED ONTO THE RENDER RESULT.
// `vitest-browser-react`'s return value does not accept an `Object.assign`ed
// key (it reads back `undefined`), so a helper that decorated it would hand
// every caller a silent `undefined` to assert against — a viewport check that
// can only ever compare `undefined` to a literal, i.e. exactly the vacuous
// shape this project exists to remove. Measured, not assumed.
return { result, observed };
}
/** Render under the provider stack WITHOUT touching the viewport. */
export function renderWithProviders(ui: React.ReactElement) {
return render(ui, { wrapper: Providers });
}
/** The 1x1 transparent PNG — same fixture, same reason, as `component-setup.tsx`. */
export const LOADABLE_IMAGE_DATA_URI =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';