` (Tear down) plus ONE `` — Mantine's `component="a"` Button
+ // renders an anchor, so a `button` count of 2 would be wrong, not stricter.
+ expect(document.querySelectorAll('table tbody button')).toHaveLength(1);
+ expect(document.querySelectorAll('table tbody a')).toHaveLength(1);
+ expect(headerWidths()).toHaveLength(5);
+ await cleanup();
+ });
+
+ test('🔴 the slug → "Tear down" gap does NOT grow with the container', async () => {
+ const { narrow, wide } = await atBothWidths(panel, slugToTeardownGap);
+ expect(narrow, 'the narrow fixture measured no gap at all').toBeGreaterThan(0);
+ expect(
+ wide,
+ `the slug→"Tear down" gap went ${narrow} → ${wide} across a ${CONTAINER_DELTA}px ` +
+ 'container increase; the measured no-ledger baseline for this table was ' +
+ '817.36 → 1381.23, which is what removing the 1368 cap re-opened'
+ ).toBeLessThanOrEqual(narrow + 1);
+ });
+
+ test('…because the ACTION column is the one that grows here', async () => {
+ // The mechanism, separately from its consequence — and the direct contrast with the
+ // other two table blocks, where the FIRST columns grow instead. Same module, opposite
+ // primary, because this table has no column that can use the room.
+ const { narrow, wide } = await atBothWidths(panel, headerWidths);
+ const actionDelta = wide[4] - narrow[4];
+ const dataDelta = wide.reduce((s, w, i) => (i === 4 ? s : s + (w - narrow[i])), 0);
+ expect(actionDelta).toBeGreaterThan(0);
+ expect(
+ actionDelta,
+ `the action column took ${px(actionDelta)} of ${CONTAINER_DELTA}px and the four data ` +
+ `columns took ${px(dataDelta)} between them`
+ ).toBeGreaterThan(dataDelta);
+ });
+});
+
+// ── table routes 4 and 5 — the two ledgers ROUND 2 GOT WRONG ────────────────
+
+/** The glyph box of an element's text, rather than the box of the cell holding it. */
+function glyphWidth(el: Element | null | undefined): number {
+ if (!el) throw new Error('no element to measure');
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ return px(range.getBoundingClientRect().width);
+}
+
+/** The first row's ` `s of the first table on the page. */
+function bodyCells(): Element[] {
+ return Array.from(document.querySelectorAll('table tbody tr:first-child > td'));
+}
+
+describe('/apps/review reports — the other table a ledger cannot help', () => {
+ /**
+ * 🔴 UNLEDGERED AND DELIBERATELY SO — `__tests__/appsWideLayout.test.ts` requires this arm
+ * BY NAME for the `no-surplus` exemption, so deleting it turns the exemption red rather
+ * than leaving it an unmeasured claim. Three ledgers were tried and every one was either
+ * taller than natural at 1200 or clipped the `lineClamp={2}` details harder.
+ */
+ const queue = () => ;
+
+ test('the queue renders its row (guards a vacuous measurement)', async () => {
+ await renderRoute(queue(), WIDE);
+ expect(headerWidths()).toHaveLength(6);
+ expect(bodyCells()).toHaveLength(6);
+ expect(document.querySelectorAll('table tbody button').length).toBeGreaterThanOrEqual(1);
+ await cleanup();
+ });
+
+ test('the reports table is no worse than natural at every width', async () => {
+ // It carries no colgroup, so "natural" is what it renders — the assertion is that the
+ // recorded band is still what the browser produces. A value pin with provenance: these
+ // are the four numbers the exemption was decided on, and a copy change that moves them
+ // should be a decision rather than a drift.
+ const heights = await atEachWidth(queue, firstRowHeight);
+ expect(document.querySelector('table > colgroup')).toBeNull();
+ expect(
+ heights,
+ `row height at ${ALL_WIDTHS.map((v) => v.width).join('/')} — the band the no-surplus ` +
+ 'exemption was measured against'
+ ).toEqual([177.88, 88.69, 88.69, 82.89]);
+ });
+
+ test('🔴 the details box is CAPPED, which is why no column could absorb the slack', async () => {
+ // The measurement that rejected `Reason` as a primary: its text is capped at 260px, so
+ // a column given the surplus renders a wider cell around an identical sentence.
+ const detailsBox = () => {
+ const cell = bodyCells()[1];
+ const texts = Array.from(cell.children);
+ return px(texts[texts.length - 1].getBoundingClientRect().width);
+ };
+ const boxes = await atEachWidth(queue, detailsBox);
+ expect(Math.max(...boxes)).toBeLessThanOrEqual(260);
+ });
+});
+
+describe('/apps/installed activity — the table that a ledger cannot help', () => {
+ /**
+ * 🔴 THIS TABLE IS DELIBERATELY UNLEDGERED, and this arm is what keeps that decision
+ * honest — `__tests__/appsWideLayout.test.ts` requires it BY NAME for the `no-surplus`
+ * exemption, so deleting it turns the exemption red rather than silently unmeasured.
+ *
+ * Its natural layout already renders every cell on one line at every width, because its
+ * max-content sum (~735px) is the container's content width at 768. Both ledgers that
+ * shipped made rows TALLER — 48.09 and 64.89 against 36.19 — and neither was visible to a
+ * width assertion at 1440/2560.
+ */
+ const panel = () => ;
+
+ test('the fixture is the RICH shape (guards a vacuous measurement)', async () => {
+ // On a passive row every cell is short and nothing can wrap, so the heights below
+ // would agree for a reason that has nothing to do with the layout.
+ await renderRoute(panel(), WIDE);
+ const cells = Array.from(document.querySelectorAll('table tbody tr:first-child > td'));
+ expect(cells).toHaveLength(5);
+ expect(cells[2].textContent).toContain('Tipped');
+ expect(cells[3].textContent).toContain('/api/v1/buzz/tip');
+ await cleanup();
+ });
+
+ test('the activity table renders ONE LINE per cell at every width', async () => {
+ // 🔴 THE ARM THE EXEMPTION IS NAMED AGAINST. Four widths, two of them below 1440,
+ // asserting HEIGHT — the three things this tier lacked when the two bad ledgers passed.
+ const heights = await atEachWidth(panel, firstRowHeight);
+ expect(
+ heights,
+ `row height at ${ALL_WIDTHS.map((v) => v.width).join('/')} — every value must be the ` +
+ 'single-line height; a taller one means a column was squeezed below its content'
+ ).toEqual([36.19, 36.19, 36.19, 36.19]);
+ });
+
+ test('🔴 DETAIL is a fixed token — no layout can give it a usable pixel', async () => {
+ // Round 2 made this the PRIMARY column on the strength of its name. Its glyph box is
+ // identical at every width, which is half of why no ledger helps this table: one of
+ // the two cells that would have to absorb the surplus cannot.
+ const glyphs = await atEachWidth(panel, () =>
+ glyphWidth(
+ (Array.from(document.querySelectorAll('table tbody tr:first-child > td'))[3] as Element)
+ .firstElementChild
+ )
+ );
+ expect(new Set(glyphs).size, `Detail glyph widths were ${glyphs.join(' / ')}`).toBe(1);
+ });
+
+ test('…and ACTION, the other candidate, is a BOUNDED sentence', async () => {
+ // The other half. It is genuinely variable — unlike `Detail` — but it stops growing,
+ // so handing it the surplus would park the remainder mid-row. Constant here because
+ // natural layout already gives it more than it needs at every width.
+ const read = () => {
+ const cell = Array.from(document.querySelectorAll('table tbody tr:first-child > td'))[2];
+ return {
+ glyph: glyphWidth(cell.firstElementChild),
+ cell: px(cell.getBoundingClientRect().width),
+ };
+ };
+ const measured = await atEachWidth(panel, read);
+ expect(new Set(measured.map((m) => m.glyph)).size).toBe(1);
+ for (const m of measured) expect(m.cell).toBeGreaterThan(m.glyph);
+ // Guard-the-guard: an empty sentence would satisfy both trivially.
+ expect(measured[0].glyph).toBeGreaterThan(100);
+ });
+
+ test('…and the two cells a ledger squeezed are each ONE line box', async () => {
+ // The mechanism behind the height, so a future change that keeps the height constant
+ // some other way is still legible. `When` broke a `YYYY-MM-DD HH:mm` stamp across three
+ // lines under the shipped ledger; `Detail`'s monospace ref broke across two.
+ const linesPerWidth = await atEachWidth(panel, () => {
+ const cells = Array.from(document.querySelectorAll('table tbody tr:first-child > td'));
+ return [lineCount(cells[0].firstElementChild), lineCount(cells[3].firstElementChild)];
+ });
+ expect(linesPerWidth).toEqual([
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ [1, 1],
+ ]);
+ });
+});
+
+describe('🔴 NO LEDGER MAKES ITS ROWS TALLER AT A NARROWER WIDTH', () => {
+ /**
+ * THE TIER-WIDE INVARIANT, and the one that would have caught both bad ledgers.
+ *
+ * A percentage share is smallest in absolute px at the NARROWEST container, so a share
+ * sized from a 1408 measurement can sit below its cell's content at 768 and 1200. The
+ * cell does not then get narrower than a width assertion expects — it gets TALLER. Every
+ * arm in this file read a width at 1440/2560 only, so two ledgers shipped green:
+ * `AppActivityPanel`'s rows were 48.09 and then 64.89 against a natural 36.19.
+ *
+ * The invariant is stated as SHAPE rather than as a number: a table's row height must not
+ * increase as the container gets narrower. It is deliberately not "equals N px" — these
+ * tables have different row contents and a literal per table would rot on any copy
+ * change — and it is not "equals the no-ledger height" either, because this tier cannot
+ * render a component with its own colgroup removed.
+ */
+ const CASES = [
+ {
+ name: '/apps/review queue',
+ ui: () => (
+
+ ),
+ },
+ { name: '/apps/review previews', ui: () => },
+ { name: '/apps/mine', ui: () => },
+ ] as const;
+
+ test('the case list covers every LEDGERED table this file can mount', () => {
+ // A loop over a list nobody pinned passes vacuously when the list shrinks.
+ expect(CASES.map((c) => c.name)).toEqual([
+ '/apps/review queue',
+ '/apps/review previews',
+ '/apps/mine',
+ ]);
+ });
+
+ test.each(CASES)('$name — the ledger costs no vertical space at any width', async ({ ui }) => {
+ // 🔴 MEASURED AGAINST THE SAME TREE WITH ITS `` REMOVED, not against a
+ // literal and not against the other widths. Rows legitimately get taller at 768 for
+ // ANY table — less width means more wrapping — so "not 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 would at THAT width, and the only honest baseline for
+ // that is natural layout of the same content. Detaching the ` ` and
+ // re-measuring gives exactly that, in one render.
+ const offenders: string[] = [];
+ for (const vp of ALL_WIDTHS) {
+ const observed = await renderRoute(ui(), vp);
+ expect(observed).toEqual({ width: vp.width, height: vp.height });
+ const withLedger = firstRowHeight();
+ const colgroup = document.querySelector('table > colgroup');
+ expect(colgroup, 'this case is supposed to be a LEDGERED table').not.toBeNull();
+ colgroup!.remove();
+ await nextLayout();
+ const natural = firstRowHeight();
+ if (withLedger > natural + 0.01) {
+ offenders.push(
+ `@${vp.width}: ${withLedger} with the ledger vs ${natural} without it ` +
+ `(+${px(withLedger - natural)})`
+ );
+ }
+ await cleanup();
+ }
+ expect(
+ offenders,
+ 'the column ledger made rows TALLER than the browser lays them out unaided — a share ' +
+ 'is below its cell content at that width, which a width assertion cannot see'
+ ).toEqual([]);
+ });
+});
+
+// ── /apps/installed — the 640px dead gap ─────────────────────────────────────
+
+describe('/apps/installed — the space-between row keeps its control near its content', () => {
+ /**
+ * The gap between the app NAME's right edge and the Manage button's left edge.
+ *
+ * 🔴 MEASURED ON THE TEXT, NOT ON ITS CELL. The row's left child is `flex: 1`, so its
+ * BOX already spans the whole row at every width — reading the cell would report a
+ * constant zero gap and pass against the defect. What actually recedes is the button
+ * relative to the glyphs, which is what a moderator or an owner sees.
+ */
+ function nameToButtonGap(): number {
+ const nameCell = Array.from(document.querySelectorAll('[data-apps-card-grid] .truncate')).at(0);
+ const button = Array.from(document.querySelectorAll('[data-apps-card-grid] button')).at(-1);
+ if (!nameCell || !button) throw new Error('the installed card did not render its row');
+ // A `range` around the text node gives the glyphs' own box rather than the flex cell's.
+ const range = document.createRange();
+ range.selectNodeContents(nameCell);
+ return px(button.getBoundingClientRect().left - range.getBoundingClientRect().right);
+ }
+
+ const grid = () => (
+
+
+
+ );
+
+ test('🔴 the gap does NOT grow when the container does', async () => {
+ // The recorded defect, as a comparison: at 1920 → 2560 the audit measured this gap
+ // growing by exactly the container's own 640px, because a full-width card hands every
+ // extra pixel to the space between the name and the button. Two named widths, because
+ // one measurement is not a claim about a dimension.
+ const { narrow, wide } = await atBothWidths(grid, nameToButtonGap);
+ expect(narrow, 'the narrow fixture measured no gap at all').toBeGreaterThan(0);
+ expect(
+ wide,
+ `the name→Manage gap went ${narrow} → ${wide} across a ${CONTAINER_DELTA}px container ` +
+ 'increase; the card grid is supposed to spend that on a second column'
+ ).toBeLessThanOrEqual(narrow);
+ });
+
+ test('…because the CARD stops tracking the container (one column, then two)', async () => {
+ // The mechanism, stated separately from its consequence so a future change that keeps
+ // the gap constant some other way is still legible. The card is full-width at 1408 and
+ // roughly half-width at 2528.
+ const { narrow, wide } = await atBothWidths(grid, () =>
+ px(document.querySelector('[data-apps-card-grid] > *')!.getBoundingClientRect().width)
+ );
+ expect(narrow).toBe(NARROW.content);
+ // Two 1fr tracks with a 16px gap: (2528 − 16) / 2 = 1256.
+ expect(wide).toBe(1256);
+ expect(wide).toBeLessThan(narrow);
+ });
+
+ test("🔴 the Hidden tab's 12px gap gives the SAME rung, measured in the browser", async () => {
+ // F8's equivalence, in the engine rather than in arithmetic. `appsCardGridColumnsAt`
+ // MIRRORS the CSS; this reads the CSS. Two tracks either way, and the child is the
+ // gap's own width narrower — which is also the only consumer the `gap` prop has, so
+ // deleting the prop is visible here as well as at its call site.
+ const { observed } = await renderAtViewport(
+
+
+
+
+
+ ,
+ WIDE
+ );
+ expect(observed).toEqual({ width: WIDE.width, height: WIDE.height });
+ const gridEl = document.querySelector('[data-apps-card-grid]') as HTMLElement;
+ expect(getComputedStyle(gridEl).columnGap).toBe('12px');
+ // Two 1fr tracks with a 12px gap: (2528 − 12) / 2 = 1258 — the same TWO columns the
+ // 16px default yields at this width, which is the whole claim.
+ expect(px((gridEl.firstElementChild as HTMLElement).getBoundingClientRect().width)).toBe(1258);
+ await cleanup();
+ });
+
+ test('🔴 ON A PHONE the card fits the screen — the `min(100%, …)` is load-bearing', async () => {
+ // 🔴 THE ONE ASSERTION THAT MAKES THAT `min()` MORE THAN A COMMENT. Without it the
+ // track floor is a flat 1200px, and neither of this file's other fixtures is narrower
+ // than that — so dropping it passed the whole suite. Measured at 390×844 with the
+ // `min()` removed: gridBox 358, gridScroll 1200, child 1200, and
+ // `document.scrollWidth` UNCHANGED — the card is CLIPPED at the grid's edge with no
+ // scrollbar and no page overflow to notice it by, which is worse than the "overflows
+ // horizontally" the docstring used to claim. This route is phone-reachable, and this
+ // component converted three of its lists from `Stack` to grid.
+ const PHONE = { width: 390, height: 844 } as const;
+ const { observed } = await renderAtViewport(
+ {grid()} ,
+ PHONE
+ );
+ expect(observed).toEqual({ width: PHONE.width, height: PHONE.height });
+ const gridEl = document.querySelector('[data-apps-card-grid]') as HTMLElement;
+ const child = gridEl.firstElementChild as HTMLElement;
+ const gridBox = px(gridEl.getBoundingClientRect().width);
+ const childBox = px(child.getBoundingClientRect().width);
+ // ONE column, and the card is inside the grid rather than hanging out of it.
+ expect(childBox).toBeLessThanOrEqual(gridBox);
+ // …and the grid is not itself a scroll container hiding the overflow.
+ expect(gridEl.scrollWidth).toBeLessThanOrEqual(Math.ceil(gridBox));
+ // The positive control on the two assertions above: the grid really is narrower than
+ // the track floor here, so this fixture CAN see the defect. Without this, a viewport
+ // that quietly grew past 1200 would make both checks vacuous.
+ expect(gridBox).toBeLessThan(1200);
+ await cleanup();
+ });
+});
diff --git a/src/components/Apps/MyAppsBody.tsx b/src/components/Apps/MyAppsBody.tsx
index d929aa5343..c66833259b 100644
--- a/src/components/Apps/MyAppsBody.tsx
+++ b/src/components/Apps/MyAppsBody.tsx
@@ -46,6 +46,7 @@ import {
sortByRecentlyUpdated,
} from '~/components/Apps/myAppsView';
import { ownerListingState, ownerStateChip } from '~/components/Apps/offsiteOwnerControls';
+import { AppsTableColgroup, APPS_MINE_COLUMNS } from '~/components/Apps/appsWideLayout';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { canOpenListingAuthoringPage } from '~/shared/constants/app-capabilities.constants';
import { formatDate } from '~/utils/date-helpers';
@@ -513,9 +514,16 @@ function AppGroup({
return (
+ {/*
+ 🔴 FIRST CHILD, BEFORE the row groups — HTML requires it there; the ordering is
+ pinned by `__tests__/appsWideLayout.test.ts`. `App` carries the icon, the name and
+ the slug and is the ledger's primary column, so the container's surplus width
+ lands there instead of being distributed as padding across four columns.
+ */}
+
- App
+ App
Cover
Status
Updated
diff --git a/src/components/Apps/OffsiteReviewQueue.tsx b/src/components/Apps/OffsiteReviewQueue.tsx
index 2b4d53f46b..e4f452a4ae 100644
--- a/src/components/Apps/OffsiteReviewQueue.tsx
+++ b/src/components/Apps/OffsiteReviewQueue.tsx
@@ -1349,6 +1349,12 @@ export function OffsiteReportsQueue() {
) : (
+ {/* 🔴 NO COLUMN LEDGER, DELIBERATELY — EXEMPT in `~/components/Apps/appsWideLayout`
+ as `no-surplus`. At 1200 this row's content wants App 240 + Reason 292 (to
+ reach its 260px details cap) + Reporter 94 + Reported 133 + Status 86 +
+ actions 414 = 1259px in 1168px of container, so SOMETHING is under-served
+ there whatever the split. Measured: every candidate ledger was either taller
+ than natural at 1200 or clipped the lineClamp-ed details harder. */}
App
@@ -1391,16 +1397,30 @@ export function OffsiteReportsQueue() {
)}
- {r.reporter?.username ?? `#${r.reporter?.id ?? '?'}`}
+ {/* 🔴 `nowrap` — these three columns carry shrink-to-content shares, and
+ a share below min-content only holds one line when min-content is the
+ whole label. A username, a date and a status badge are each one
+ token; wrapping them was what made these rows taller than the
+ browser's own layout. */}
+
+ {r.reporter?.username ?? `#${r.reporter?.id ?? '?'}`}
+
-
+
- {formatDate(r.createdAt)}
+
+ {formatDate(r.createdAt)}
+
-
+
{statusChip.label}
diff --git a/src/components/Apps/ReportTabs.tsx b/src/components/Apps/ReportTabs.tsx
index d518ed6616..f7c852b505 100644
--- a/src/components/Apps/ReportTabs.tsx
+++ b/src/components/Apps/ReportTabs.tsx
@@ -26,6 +26,10 @@ import {
type ScopeVerdictsView,
type SecurityAuditView,
} from '~/components/Apps/agentReviewReport';
+import {
+ AppsTableColgroup,
+ APPS_AGENT_REPORT_SCOPE_COLUMNS,
+} from '~/components/Apps/appsWideLayout';
/**
* App Blocks — AGENTIC MOD CODE-REVIEW report renderer (P2, Phase-2 redesign).
@@ -534,6 +538,10 @@ export function ScopesTab({
fz="xs"
data-testid="scope-verdicts-table"
>
+ {/* 🔴 FIRST CHILD, BEFORE the row groups — see `appsWideLayout`. Reachable on
+ `/apps/review/[publishRequestId]` (OnsiteReviewModalBody → AgentReviewPanel),
+ which takes the full container. Inert in the modal, load-bearing on the page. */}
+
Scope
diff --git a/src/components/Apps/UnifiedReviewList.tsx b/src/components/Apps/UnifiedReviewList.tsx
index 69ca4b2629..90485e48c3 100644
--- a/src/components/Apps/UnifiedReviewList.tsx
+++ b/src/components/Apps/UnifiedReviewList.tsx
@@ -9,6 +9,7 @@ import {
import { useMemo, useState } from 'react';
import type { OffsitePendingRow } from '~/components/Apps/OffsiteReviewQueue';
import { canRetriggerBuild } from '~/components/Apps/deploy-status';
+import { AppsTableColgroup, APPS_REVIEW_QUEUE_COLUMNS } from '~/components/Apps/appsWideLayout';
import {
mergeReviewRows,
offsiteRequestToUnifiedRow,
@@ -118,10 +119,29 @@ export function UnifiedReviewList({
{rows.length > 0 && (
+ {/*
+ 🔴 FIRST CHILD, BEFORE the row groups — HTML requires it there, and
+ `__tests__/appsWideLayout.test.ts` is what enforces it (see the note on that
+ guard for what the PIXELS can and cannot see about the ordering). Its ledger
+ is keyed on `showDeploy`, i.e. on the same DATA that decides whether the
+ Deploy column exists, so the two can never disagree about the column COUNT.
+ Why this table has one at all: `/apps/review` used to cap its whole page at
+ 1368 because these columns could not spend the container and the Review
+ button drifted away from its row.
+ */}
+
Kind
- App
+ {/* The PRIMARY column — no width in the ledger, so it takes the slack.
+ The testid is what `AppsWideLayout.geometry.test.tsx` measures. */}
+ App
Submitter
{dateLabel}
{showDeploy && Deploy }
@@ -275,6 +295,11 @@ function UnifiedReviewRowView({
@@ -297,9 +322,12 @@ function UnifiedReviewRowView({
-
+ {/* Same reason as the kind badge: a timestamp is one token and must not wrap. */}
+
- {row.submittedAt.toLocaleString()}
+
+ {row.submittedAt.toLocaleString()}
+
{showDeploy && (
diff --git a/src/components/Apps/__tests__/appsPageLayout.test.ts b/src/components/Apps/__tests__/appsPageLayout.test.ts
index 5e77a3a131..8b2014fa6e 100644
--- a/src/components/Apps/__tests__/appsPageLayout.test.ts
+++ b/src/components/Apps/__tests__/appsPageLayout.test.ts
@@ -126,8 +126,14 @@ describe('AppsPageLayout takes NO per-page container width', () => {
// The state, not a keyword: the Container's `size` is THE shared constant.
expect(src).toMatch(/ {
const src = layoutSrc();
expect(src.length).toBeGreaterThan(1000);
expect(src).toMatch(/export function AppsPageLayout/);
- expect(src).toMatch(/measure\?:\s*number/);
+ // `AppsMeasure` since the band pass — a number OR a `{min,max,grow}` band. The prop
+ // still exists and is still the only width the caller controls, which is what this
+ // guard-the-guard is for.
+ expect(src).toMatch(/measure\?:\s*AppsMeasure/);
});
});
diff --git a/src/components/Apps/__tests__/appsPageLayoutRender.test.ts b/src/components/Apps/__tests__/appsPageLayoutRender.test.ts
index 95ebe5a83c..a58ac03e05 100644
--- a/src/components/Apps/__tests__/appsPageLayoutRender.test.ts
+++ b/src/components/Apps/__tests__/appsPageLayoutRender.test.ts
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { MantineProvider } from '@mantine/core';
import { Window } from 'happy-dom';
import { describe, expect, it, vi } from 'vitest';
+import type { AppsMeasure } from '~/components/Apps/appsPageWidths';
/**
* `/apps` chrome — the measure box, asserted on the RENDERED TREE, in the tier that
@@ -49,13 +50,26 @@ vi.mock('~/components/Apps/AppsSubNav', () => ({
}));
const { AppsPageLayout } = await import('~/components/Apps/AppsPageLayout');
-const { APPS_PAGE_MEASURES, APPS_PAGE_CONTAINER_WIDTH } = await import(
+const { APPS_PAGE_MEASURES, APPS_PAGE_CONTAINER_WIDTH, appsMeasureCss } = await import(
'~/components/Apps/appsPageWidths'
);
/** Mantine emits `max-width:calc(rem * var(--mantine-scale))` for `maw={n}`. */
const remOf = (px: number) => `${px / 16}rem`;
+/**
+ * The substring the rendered `max-width` must contain for a given measure.
+ *
+ * 🔴 TWO SHAPES, ONE ASSERTION. A numeric measure is converted to `rem` by Mantine; a
+ * BAND is a `clamp(…)` string, which Mantine's `rem()` early-returns untouched. Reading
+ * both through the module's own `appsMeasureCss` is deliberate — a second hand-written
+ * copy of the formatter here would agree with itself while disagreeing with the layout.
+ */
+const expectedMaxWidth = (measure: AppsMeasure): string => {
+ const css = appsMeasureCss(measure);
+ return typeof css === 'number' ? remOf(css) : css;
+};
+
type Tree = {
/**
* The measure box wrapping the page BODY, or `null` when there is no measure.
@@ -79,7 +93,7 @@ type Tree = {
band: Element;
};
-function renderLayout(measure?: number, withHeader = false): Tree {
+function renderLayout(measure?: AppsMeasure, withHeader = false): Tree {
const html = renderToStaticMarkup(
createElement(
MantineProvider,
@@ -236,19 +250,31 @@ describe('the measure box, on the rendered tree', () => {
});
it('every route measure renders its own distinct max-width', () => {
- const entries = Object.entries(APPS_PAGE_MEASURES);
- // Guard-the-guard: an empty map would make the loop pass vacuously.
- expect(entries.length).toBeGreaterThanOrEqual(8);
+ const entries = Object.entries(APPS_PAGE_MEASURES) as [string, AppsMeasure][];
+ // Guard-the-guard: an empty map would make the loop pass vacuously. Seven since
+ // `/apps/review` gave up its cap and joined the full-container list.
+ expect(entries.length).toBeGreaterThanOrEqual(7);
const rendered = new Set();
for (const [route, measure] of entries) {
const t = renderLayout(measure);
expect(t.measureBox, `${route} rendered no measure box`).not.toBeNull();
- expect(t.measureBox!.getAttribute('style'), route).toContain(remOf(measure));
+ expect(t.measureBox!.getAttribute('style'), route).toContain(expectedMaxWidth(measure));
rendered.add(t.measureBox!.getAttribute('style') ?? '');
}
- // Three measure CLASSES, so three distinct rendered widths — proof the fixture
- // varies the dimension rather than feeding one value eight times.
- expect(rendered.size).toBe(3);
+ // Two measure CLASSES, so two distinct rendered widths — proof the fixture varies
+ // the dimension rather than feeding one value seven times.
+ expect(rendered.size).toBe(2);
+ });
+
+ it('🔴 a BAND reaches the DOM as a clamp, not as a rem floor', () => {
+ // The failure this catches is a measure that renders its `min` and stops growing —
+ // which looks completely correct at 1440 and silently un-does the whole band on a
+ // wide screen. `remOf(min)` must NOT be what lands in the attribute.
+ const band = { min: 1068, max: 1368, grow: 55 } as const;
+ const t = renderLayout(band);
+ const style = t.measureBox!.getAttribute('style') ?? '';
+ expect(style).toContain('clamp(1068px, 55%, 1368px)');
+ expect(style).not.toContain(remOf(1068));
});
it('🔴 the HEADER is bounded by the same measure as the body', () => {
diff --git a/src/components/Apps/__tests__/appsPageWidths.test.ts b/src/components/Apps/__tests__/appsPageWidths.test.ts
index 35e8f4ffb4..05df1a1ee3 100644
--- a/src/components/Apps/__tests__/appsPageWidths.test.ts
+++ b/src/components/Apps/__tests__/appsPageWidths.test.ts
@@ -4,15 +4,21 @@ import path from 'path';
import ts from 'typescript';
import { describe, expect, test } from 'vitest';
import {
+ APPS_CARD_LIST_GAP,
+ APPS_CARD_LIST_MIN_COLUMN,
APPS_CONTAINER_GUTTER,
APPS_FULL_BLEED_PAGES,
APPS_FULL_MEASURE_PAGES,
- APPS_NARROW_TABLE_MEASURE,
+ APPS_LEGACY_CONTAINER_WIDTH,
APPS_PAGE_CONTAINER_WIDTH,
APPS_PAGE_MEASURES,
APPS_READABLE_MEASURE,
APPS_REDIRECT_ONLY_PAGES,
APPS_TWO_COLUMN_DETAIL_MEASURE,
+ appsMeasureCss,
+ isAppsMeasureBand,
+ type AppsMeasure,
+ type AppsMeasureBand,
} from '~/components/Apps/appsPageWidths';
import * as widthsModule from '~/components/Apps/appsPageWidths';
import {
@@ -410,28 +416,61 @@ describe('the container is uniform, and it is the only container', () => {
});
});
+/** The content width a body gets from the CURRENT shared container. */
+const USABLE = APPS_PAGE_CONTAINER_WIDTH - APPS_CONTAINER_GUTTER;
+/** …and from the container this module shipped with before the ultrawide pass. */
+const LEGACY_USABLE = APPS_LEGACY_CONTAINER_WIDTH - APPS_CONTAINER_GUTTER;
+
+/** What a band resolves to at a given container CONTENT width — the `clamp()`, in JS. */
+function bandAt(band: AppsMeasureBand, contentWidth: number): number {
+ return Math.min(band.max, Math.max(band.min, (band.grow / 100) * contentWidth));
+}
+
describe('APPS_PAGE_MEASURES — the decided CONTENT measure per route', () => {
- test('the narrow-table measure is 1368 and only /apps/review takes it', () => {
- expect(APPS_NARROW_TABLE_MEASURE).toBe(1368);
- expect(APPS_PAGE_MEASURES['/apps/review']).toBe(1368);
- const takers = Object.entries(APPS_PAGE_MEASURES)
- .filter(([, m]) => m === APPS_NARROW_TABLE_MEASURE)
- .map(([r]) => r);
- expect(takers).toEqual(['/apps/review']);
+ test('🔴 the NARROW-TABLE class is gone, and /apps/review takes no measure at all', () => {
+ // It existed for exactly one route and for exactly one reason: four short columns
+ // could not spend the container, so the surplus landed as padding between the last
+ // column and the Review button. The columns are proportional now
+ // (`APPS_REVIEW_QUEUE_COLUMNS`), so the cap would hide the fix rather than help it.
+ //
+ // 🔴 THE CONSTANT ITSELF IS ASSERTED ABSENT, not 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 — the module has already lost `APPS_PAGE_WIDTHS` and
+ // `MY_APPS_CONTAINER_SIZE` the same way.
+ expect(widthsModule).not.toHaveProperty('APPS_NARROW_TABLE_MEASURE');
+ expect(APPS_PAGE_MEASURES).not.toHaveProperty('/apps/review');
+ expect(APPS_FULL_MEASURE_PAGES).toContain('/apps/review');
+ // Guard-the-guard: an empty namespace satisfies every `not.toHaveProperty`.
+ expect(widthsModule).toHaveProperty('APPS_READABLE_MEASURE');
});
- test('the two-column detail measure is 1288 and only the store preview takes it', () => {
- expect(APPS_TWO_COLUMN_DETAIL_MEASURE).toBe(1288);
- expect(APPS_PAGE_MEASURES['/apps/store-preview/[slug]']).toBe(1288);
+ test('the two-column detail band is 1288 → 1600 and only the store preview takes it', () => {
+ expect(APPS_TWO_COLUMN_DETAIL_MEASURE).toEqual({ min: 1288, max: 1600, grow: 65 });
+ expect(APPS_PAGE_MEASURES['/apps/store-preview/[slug]']).toBe(APPS_TWO_COLUMN_DETAIL_MEASURE);
// 🔴 Pinned in BOTH directions so a later "tidy-up" that folds the detail into
// another class fails here rather than silently squeezing the right rail
- // (readable) or putting the markdown description on a ~1250px measure (full).
+ // (readable) or putting the markdown description on a ~1685px measure (full).
expect(APPS_PAGE_MEASURES['/apps/store-preview/[slug]']).not.toBe(APPS_READABLE_MEASURE);
- expect(APPS_PAGE_MEASURES['/apps/store-preview/[slug]']).not.toBe(APPS_PAGE_CONTAINER_WIDTH);
+ const takers = Object.entries(APPS_PAGE_MEASURES)
+ .filter(([, m]) => m === APPS_TWO_COLUMN_DETAIL_MEASURE)
+ .map(([r]) => r);
+ expect(takers).toEqual(['/apps/store-preview/[slug]']);
});
- test('the readable measure is 1068, and these six form/prose routes take it', () => {
- expect(APPS_READABLE_MEASURE).toBe(1068);
+ test('🔴 the two-column CEILING keeps its markdown column inside the readable floor', () => {
+ // The ceiling is DERIVED rather than picked: the left column is prose at the `md`
+ // 8/12 span, so the page may only be as wide as leaves that column at or under the
+ // readable band's own floor. Stated as the relationship, so moving either number
+ // without the other fails here.
+ const mainColumn = (APPS_TWO_COLUMN_DETAIL_MEASURE.max * 8) / 12;
+ expect(mainColumn).toBeLessThanOrEqual(APPS_READABLE_MEASURE.min);
+ // …and it is not needlessly conservative — one more Mantine grid step (a 1700 cap)
+ // would break it, which is what makes the bound meaningful rather than arbitrary.
+ expect((1700 * 8) / 12).toBeGreaterThan(APPS_READABLE_MEASURE.min);
+ });
+
+ test('the readable band is 1068 → 1368, and these six form/prose routes take it', () => {
+ expect(APPS_READABLE_MEASURE).toEqual({ min: 1068, max: 1368, grow: 55 });
const takers = Object.entries(APPS_PAGE_MEASURES)
.filter(([, m]) => m === APPS_READABLE_MEASURE)
.map(([r]) => r)
@@ -447,37 +486,125 @@ describe('APPS_PAGE_MEASURES — the decided CONTENT measure per route', () => {
]);
});
- test('every measure is one of the THREE decided values — no fourth hand-picked number', () => {
+ test('every measure is one of the TWO decided classes — no third hand-picked number', () => {
// The whole point of the module is that there are a few CLASSES of apps page,
// not eleven bespoke numbers. A new page must join a class, or the class list
// must grow deliberately — failing here first.
for (const [route, measure] of Object.entries(APPS_PAGE_MEASURES)) {
- expect(
- [APPS_NARROW_TABLE_MEASURE, APPS_TWO_COLUMN_DETAIL_MEASURE, APPS_READABLE_MEASURE],
- `${route}`
- ).toContain(measure);
+ expect([APPS_TWO_COLUMN_DETAIL_MEASURE, APPS_READABLE_MEASURE], `${route}`).toContain(
+ measure
+ );
}
// Pin the class list itself, as literals. Without this the check above is
- // satisfied by ANY set of constants, including a fourth one added silently.
- expect([
- APPS_NARROW_TABLE_MEASURE,
- APPS_TWO_COLUMN_DETAIL_MEASURE,
- APPS_READABLE_MEASURE,
- ]).toEqual([1368, 1288, 1068]);
+ // satisfied by ANY set of constants, including a third one added silently.
+ expect([APPS_TWO_COLUMN_DETAIL_MEASURE, APPS_READABLE_MEASURE]).toEqual([
+ { min: 1288, max: 1600, grow: 65 },
+ { min: 1068, max: 1368, grow: 55 },
+ ]);
});
- test('🔴 a measure is always strictly inside the container', () => {
- // A measure ≥ the container is a no-op that reads as a decision. A measure
- // larger than the container's usable width is worse: it silently does nothing
- // while claiming a class.
- const usable = APPS_PAGE_CONTAINER_WIDTH - APPS_CONTAINER_GUTTER;
- for (const [route, measure] of Object.entries(APPS_PAGE_MEASURES)) {
- expect(measure, `${route} must actually narrow the body`).toBeLessThan(usable);
- expect(measure, `${route} must be a positive px value`).toBeGreaterThan(0);
+ test('🔴 a measure is always strictly inside the container, at BOTH ends of its band', () => {
+ // A measure ≥ the container is a no-op that reads as a decision. For a band it is
+ // the CEILING that has to clear it — checking the floor alone would pass a band that
+ // silently stops narrowing anything on a wide screen.
+ for (const [route, measure] of Object.entries(APPS_PAGE_MEASURES) as [string, AppsMeasure][]) {
+ const min = isAppsMeasureBand(measure) ? measure.min : measure;
+ const max = isAppsMeasureBand(measure) ? measure.max : measure;
+ expect(max, `${route} must actually narrow the body at its widest`).toBeLessThan(USABLE);
+ expect(min, `${route} must be a positive px value`).toBeGreaterThan(0);
+ expect(max, `${route}'s band must not be inverted`).toBeGreaterThanOrEqual(min);
}
});
});
+describe('🔴 a BAND grows only where the container grew', () => {
+ const bands = Object.entries(APPS_PAGE_MEASURES).filter(
+ (entry): entry is [string, AppsMeasureBand] => isAppsMeasureBand(entry[1])
+ );
+
+ test('the sweep found bands to check (guards a vacuous loop)', () => {
+ // Every measure is a band today, and if that ever stops being true this loop would
+ // pass by checking nothing.
+ expect(bands.length).toBeGreaterThanOrEqual(7);
+ expect(new Set(bands.map(([, b]) => b)).size).toBe(2);
+ });
+
+ test('every band is still at its FLOOR at the old 1920 container', () => {
+ // The provenance rule this module has held since the container/measure split: a
+ // width pass may add width on screens that got wider, never re-decide what a 1440 or
+ // 1920 monitor already showed. `grow` is what makes that true, so it is asserted as
+ // arithmetic rather than described in the docstring.
+ expect(LEGACY_USABLE).toBe(1888);
+ for (const [route, band] of bands) {
+ expect(bandAt(band, LEGACY_USABLE), `${route} moved at the old container width`).toBe(
+ band.min
+ );
+ }
+ });
+
+ test('…and every band reaches its CEILING inside the current one', () => {
+ // The other end: a band whose `grow` is too small never reaches its own max, so the
+ // ceiling would be a number that reads as a decision and does nothing.
+ expect(USABLE).toBe(2528);
+ for (const [route, band] of bands) {
+ expect(bandAt(band, USABLE), `${route} never reaches its ceiling`).toBe(band.max);
+ }
+ });
+
+ test('🔴 the ramp is a RAMP — each band is strictly between its ends somewhere', () => {
+ // Without this, `grow: 100` would satisfy both tests above while jumping every
+ // measured page straight to its ceiling the moment the container passed 1888.
+ for (const [route, band] of bands) {
+ const midpoint = bandAt(band, (LEGACY_USABLE + USABLE) / 2);
+ expect(midpoint, `${route} jumps rather than ramps`).toBeGreaterThan(band.min);
+ expect(midpoint, `${route} jumps rather than ramps`).toBeLessThan(band.max);
+ }
+ });
+
+ test('appsMeasureCss renders a band as a clamp and a number untouched', () => {
+ expect(appsMeasureCss(APPS_READABLE_MEASURE)).toBe('clamp(1068px, 55%, 1368px)');
+ expect(appsMeasureCss(APPS_TWO_COLUMN_DETAIL_MEASURE)).toBe('clamp(1288px, 65%, 1600px)');
+ // A number is passed through so Mantine can convert it to rem as it always has.
+ expect(appsMeasureCss(777)).toBe(777);
+ // POSITIVE CONTROL on the formatter: feed a band no real measure equals and watch
+ // every term move, so the two assertions above cannot be satisfied by a hardcode.
+ expect(appsMeasureCss({ min: 111, max: 222, grow: 33 })).toBe('clamp(111px, 33%, 222px)');
+ });
+
+ test('isAppsMeasureBand separates the two shapes', () => {
+ expect(isAppsMeasureBand(APPS_READABLE_MEASURE)).toBe(true);
+ expect(isAppsMeasureBand(1068)).toBe(false);
+ });
+});
+
+describe('🔴 the card-list column ladder steps exactly where the surplus appeared', () => {
+ /** The CSS `repeat(auto-fill, minmax(min, 1fr))` count, mirrored in JS. */
+ const columnsAt = (w: number) =>
+ Math.max(
+ 1,
+ Math.floor((w + APPS_CARD_LIST_GAP) / (APPS_CARD_LIST_MIN_COLUMN + APPS_CARD_LIST_GAP))
+ );
+
+ test('one column through the OLD container, two in the current one', () => {
+ // The whole justification for `/apps/installed` becoming a grid: it must change
+ // nothing a 1440 or 1920 monitor showed, and spend the 640px the ultrawide pass
+ // added. A min column of 1200 is what puts the step between the two.
+ expect(columnsAt(1408)).toBe(1); // 1440 viewport
+ expect(columnsAt(LEGACY_USABLE)).toBe(1); // the old 1920 container
+ expect(columnsAt(USABLE)).toBe(2); // the current 2560 container
+ });
+
+ test('the step is not sitting on either fixture width', () => {
+ // A ladder whose rung lands ON a width the tests measure cannot detect an
+ // off-by-one. The second column arrives at 2416, which is 528 above the old
+ // container's content width and 112 below the current one.
+ expect(columnsAt(2415)).toBe(1);
+ expect(columnsAt(2416)).toBe(2);
+ expect(USABLE - 2416).toBeGreaterThan(64);
+ expect(2416 - LEGACY_USABLE).toBeGreaterThan(64);
+ });
+});
+
describe('🔴 the measures preserve the OLD rendered content widths exactly', () => {
/**
* THE +32px TRAP, PINNED. Mantine's `Container` is border-box: `size={N}` renders
@@ -493,20 +620,23 @@ describe('🔴 the measures preserve the OLD rendered content widths exactly', (
expect(APPS_CONTAINER_GUTTER).toBe(32);
});
+ // 🔴 THE PROVENANCE IS NOW THE BAND'S **FLOOR**, and that is the point of a floor: a
+ // band whose min drifted off the old container width would silently re-decide what a
+ // 1440 monitor shows, which is the confounded change the gutter arithmetic above
+ // exists to keep out of a width pass.
test.each([
- ['narrow table', APPS_NARROW_TABLE_MEASURE, 1400],
['two-column detail', APPS_TWO_COLUMN_DETAIL_MEASURE, 1320],
['readable', APPS_READABLE_MEASURE, 1100],
- ])('%s: measure = old container width − gutter', (_label, measure, oldContainerWidth) => {
- expect(measure).toBe(oldContainerWidth - APPS_CONTAINER_GUTTER);
+ ])('%s: band floor = old container width − gutter', (_label, band, oldContainerWidth) => {
+ expect(band.min).toBe(oldContainerWidth - APPS_CONTAINER_GUTTER);
});
- test('the two-column measure equals the MODEL DETAIL page content width', () => {
+ test('the two-column FLOOR equals the MODEL DETAIL page content width', () => {
// Its documented justification is "the same width as the model detail page",
// which renders `` — Mantine's `xl` is 1320 border-box, so
// its CONTENT is 1288. Stating the claim in content terms is what makes it true.
const MANTINE_XL_CONTAINER = 1320;
- expect(APPS_TWO_COLUMN_DETAIL_MEASURE).toBe(MANTINE_XL_CONTAINER - APPS_CONTAINER_GUTTER);
+ expect(APPS_TWO_COLUMN_DETAIL_MEASURE.min).toBe(MANTINE_XL_CONTAINER - APPS_CONTAINER_GUTTER);
});
});
@@ -585,7 +715,10 @@ describe('🔴 /apps/mine is wide enough for its table, as a RELATIONSHIP', () =
// counterfactual so the previous test cannot pass for the wrong reason.
expect(APPS_PAGE_MEASURES).not.toHaveProperty('/apps/mine');
expect(APPS_FULL_MEASURE_PAGES).toContain('/apps/mine');
- expect(APPS_READABLE_MEASURE - SUBMISSIONS_CONTAINER_CHROME).toBeLessThan(
+ // The readable band's CEILING is used, not its floor: the counterfactual has to be
+ // "even at its widest, that class would still clip this table", or a band that grew
+ // past the floor would make this pass for a reason that is no longer true.
+ expect(APPS_READABLE_MEASURE.max - SUBMISSIONS_CONTAINER_CHROME).toBeLessThan(
SUBMISSIONS_TABLE_MIN_WIDTH
);
});
diff --git a/src/components/Apps/__tests__/appsWideLayout.test.ts b/src/components/Apps/__tests__/appsWideLayout.test.ts
new file mode 100644
index 0000000000..0e70c82acb
--- /dev/null
+++ b/src/components/Apps/__tests__/appsWideLayout.test.ts
@@ -0,0 +1,725 @@
+import fs from 'fs';
+import path from 'path';
+import ts from 'typescript';
+import { describe, expect, test } from 'vitest';
+import {
+ APPS_ACTIVE_PREVIEWS_COLUMNS,
+ APPS_CARD_LIST_GAP,
+ APPS_CARD_LIST_MIN_COLUMN,
+ APPS_AGENT_REPORT_SCOPE_COLUMNS,
+ APPS_FULL_MEASURE_CONTENT_WIDTH,
+ APPS_LEGACY_CONTENT_WIDTH,
+ APPS_MINE_COLUMNS,
+ APPS_MOD_LISTINGS_COLUMNS,
+ APPS_REVENUE_COLUMNS,
+ APPS_REVIEW_QUEUE_COLUMNS,
+ APPS_TABLE_COLUMN_LEDGERS,
+ appsCardGridColumnsAt,
+ appsTableColumnProblems,
+ type AppsTableColumns,
+} from '~/components/Apps/appsWideLayout';
+import * as LEDGER_EXPORTS from '~/components/Apps/appsWideLayout';
+
+/**
+ * `appsWideLayout` — the RULE and the LEDGERS, in the blocking `unit` project.
+ *
+ * WHAT THIS FILE OWNS: the DATA (one primary per ledger, the percentages leave room for
+ * it, the card ladder steps where it should), the COVERAGE (every headed table under the
+ * apps components has a ledger or a verified exemption), the PLACEMENT (the colgroup is
+ * its table's first child) and the ledger↔table COLUMN COUNT.
+ *
+ * WHAT `AppsWideLayout.geometry.test.tsx` OWNS: the resolved WIDTHS.
+ *
+ * ⚠️ THAT SPLIT IS NOT THE ONE IT LOOKS LIKE, AND IT WAS MEASURED RATHER THAN ASSUMED.
+ * The ordering guard here was written believing a misplaced `` is ignored by the
+ * browser — i.e. that geometry would be the real catch. Two mutations settled it on
+ * 2026-09-04, and re-confirmed after the file grew. Moving the element after
+ * `` left EVERY geometry assertion
+ * GREEN and turned only this file red: React inserts nodes through the DOM API, so the HTML
+ * parser's table foster-parenting never runs and Chromium honours the columns wherever the
+ * element sits. DELETING the colgroup outright turns the geometry assertions red — the
+ * positive control proving that green was about placement, not about that tier being blind.
+ * So placement and column count are enforced HERE and only here.
+ */
+
+const repoFile = (rel: string) => path.resolve(__dirname, '../../../..', rel);
+const srcOf = (rel: string) => fs.readFileSync(repoFile(rel), 'utf8');
+
+/**
+ * Every file under `src` that RENDERS ``, excluding tests.
+ *
+ * Used to verify the exemption allowlist below, so a "nothing renders this" claim is
+ * re-derived rather than believed. Text-scanned rather than parsed on purpose: this is a
+ * search for a live call site, and being over-inclusive (a commented-out render would
+ * count) fails CLOSED — it would refuse an exemption, never grant one.
+ */
+function renderSitesOf(component: string): string[] {
+ const hits: string[] = [];
+ const needle = new RegExp(`<${component}[\\s/>]`);
+ const walk = (dir: string) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name !== '__tests__' && entry.name !== 'node_modules') walk(full);
+ continue;
+ }
+ if (!entry.name.endsWith('.tsx') || entry.name.includes('.test.')) continue;
+ if (needle.test(fs.readFileSync(full, 'utf8'))) {
+ hits.push(path.relative(repoFile('.'), full).replace(/\\/g, '/'));
+ }
+ }
+ };
+ walk(repoFile('src'));
+ return hits.sort();
+}
+
+/**
+ * The file with every BLOCK COMMENT removed.
+ *
+ * 🔴 A COMMENT IS NOT A RENDER, AND THIS FILE LEARNED IT THE EXPENSIVE WAY ROUND. An
+ * earlier ordering guard read `indexOf(' text.replace(/\/\*[\s\S]*?\*\//g, '');
+const codeOf = (rel: string) => stripBlockComments(srcOf(rel));
+
+describe('appsTableColumnProblems — the rule, on inputs it must REJECT', () => {
+ // 🔴 THE VALIDATOR'S ONLY REAL INPUTS ALL PASS, so without these it could be gutted
+ // and the suite would stay green. Each row below is a ledger that must be reported.
+ const cases: { name: string; columns: AppsTableColumns; offends: boolean }[] = [
+ { name: 'a valid ledger', columns: [10, null, 14, 16, 12], offends: false },
+ { name: 'a two-column ledger', columns: [null, 30], offends: false },
+ { name: 'NO primary — nothing absorbs the slack', columns: [10, 20, 30], offends: true },
+ { name: 'TWO primaries — the slack splits', columns: [null, 20, null], offends: true },
+ { name: 'a zero-width column', columns: [null, 0, 20], offends: true },
+ { name: 'a negative width', columns: [null, -5, 20], offends: true },
+ { name: 'a non-finite width', columns: [null, Number.NaN], offends: true },
+ { name: 'the percentages claim exactly 100', columns: [null, 50, 50], offends: true },
+ { name: 'the percentages claim more than 100', columns: [null, 60, 60], offends: true },
+ { name: 'an empty ledger', columns: [], offends: true },
+ ];
+
+ test.each(cases)('$name', ({ columns, offends }) => {
+ expect(appsTableColumnProblems('fixture', columns).length > 0).toBe(offends);
+ });
+
+ test('the table exercises both verdicts and no row is a duplicate', () => {
+ // Guard-the-guard: duplicated rows inflate the table without adding coverage, and a
+ // table of only-bad rows is satisfied by a validator that rejects everything.
+ const keys = cases.map((c) => JSON.stringify(c.columns));
+ expect(new Set(keys).size).toBe(keys.length);
+ expect(cases.some((c) => c.offends)).toBe(true);
+ expect(cases.some((c) => !c.offends)).toBe(true);
+ });
+
+ test('the message names WHICH ledger and WHAT is wrong', () => {
+ // A guard whose message does not say what is wrong sends the next reader hunting.
+ const [msg] = appsTableColumnProblems('my apps', [10, 20, 30]);
+ expect(msg).toContain('my apps');
+ expect(msg).toContain('exactly ONE');
+ const [over] = appsTableColumnProblems('my apps', [null, 60, 60]);
+ expect(over).toContain('120%');
+ // …and the two defects do not print the same sentence.
+ expect(over).not.toContain('exactly ONE');
+ });
+});
+
+describe('every shipped ledger is valid', () => {
+ test('the sweep found the ledgers (guards a vacuous loop)', () => {
+ // A loop over an empty record passes having checked nothing.
+ expect(Object.keys(APPS_TABLE_COLUMN_LEDGERS)).toHaveLength(8);
+ });
+
+ test('no ledger has a problem', () => {
+ const problems = Object.entries(APPS_TABLE_COLUMN_LEDGERS).flatMap(([label, columns]) =>
+ appsTableColumnProblems(label, columns)
+ );
+ expect(problems).toEqual([]);
+ });
+
+ test('each ledger has the length we DECIDED (a value pin, not a relationship)', () => {
+ // 🔴 THE TITLE USED TO SAY "the column COUNT ITS TABLE RENDERS" — a relationship the
+ // body never checked, because it compares `.length` against a literal and never opens
+ // a component. Measured: adding a `` without touching the ledger left this
+ // GREEN for every table, and green at BOTH tiers for two of them. The relationship is
+ // now checked against the parsed tables, further down; this stays as what it always
+ // was — a value pin, honestly labelled, so a silent re-tune is still visible.
+ expect(APPS_REVIEW_QUEUE_COLUMNS.withoutDeploy).toHaveLength(5); // Kind App Submitter date action
+ expect(APPS_REVIEW_QUEUE_COLUMNS.withDeploy).toHaveLength(6); // …plus Deploy
+ expect(APPS_MINE_COLUMNS).toHaveLength(4); // App Cover Status Updated
+ expect(APPS_MOD_LISTINGS_COLUMNS).toHaveLength(5); // App Owner Category Reviews actions
+ expect(APPS_REVENUE_COLUMNS.withApp).toHaveLength(7); // Date App Scope Buzz Gross Share Status
+ expect(APPS_REVENUE_COLUMNS.scoped).toHaveLength(6); // …minus App
+ expect(APPS_ACTIVE_PREVIEWS_COLUMNS).toHaveLength(5); // App Version State Age actions
+ expect(APPS_AGENT_REPORT_SCOPE_COLUMNS).toHaveLength(6); // Scope Used Justified Sensitive Evidence Notes
+ });
+
+ test('🔴 the two-shape ledgers differ by exactly one column', () => {
+ // Both pairs exist because ONE optional column exists. A pair that differed by two
+ // would mean a width-conditional column set had crept in, which is the thing the
+ // module's docstring forbids.
+ expect(
+ APPS_REVIEW_QUEUE_COLUMNS.withDeploy.length - APPS_REVIEW_QUEUE_COLUMNS.withoutDeploy.length
+ ).toBe(1);
+ expect(APPS_REVENUE_COLUMNS.withApp.length - APPS_REVENUE_COLUMNS.scoped.length).toBe(1);
+ });
+
+ test('🔴 the PRIMARY column is where the ledger says it is — for EVERY ledger', () => {
+ // 🔴 THIS PIN WENT FROM 6/6 TO 6/10 WHEN FOUR LEDGERS WERE ADDED, AND THE GAP IS WHAT
+ // LET TWO WRONG DECISIONS SHIP. Measured at that revision: moving the primary in ALL
+ // FOUR new ledgers left the unit tier 31/31 green, and geometry caught only
+ // `ActivePreviewsPanel`. So the choice this module calls "a decision with two cases,
+ // and getting it wrong makes the defect WORSE" was unguarded at both tiers for
+ // `OffsiteReportsQueue`, `AppActivityPanel` and `ReportTabs` — and it WAS wrong for the
+ // first two. Keyed off the ledger record so a new entry cannot be added without one.
+ const PRIMARY_AT: Record = {
+ 'review queue (pending/rejected)': 1, // App — slug + optional title
+ 'review queue (approved)': 1, // App
+ 'my apps': 0, // App — icon + name + slug
+ 'moderation listings': 0, // App — slug + kind/status chips
+ 'revenue (unscoped)': 1, // App — link to the per-app page
+ 'revenue (scoped)': 1, // Scope — there is no App column
+ 'active previews': 4, // actions — case (b), no cell can use the room
+ 'agent report scopes': 5, // Notes — uncapped reviewer prose
+ };
+ // The map and the ledger record must name the SAME set, or a ledger added without a
+ // decision would simply be skipped.
+ expect(Object.keys(PRIMARY_AT).sort()).toEqual(Object.keys(APPS_TABLE_COLUMN_LEDGERS).sort());
+ for (const [label, index] of Object.entries(PRIMARY_AT)) {
+ expect(APPS_TABLE_COLUMN_LEDGERS[label].indexOf(null), `${label}'s primary column`).toBe(
+ index
+ );
+ }
+ });
+
+ test('🔴 the primary column is left a MEANINGFUL share, not a sliver', () => {
+ // `< 100%` alone is satisfied by a ledger claiming 99%. The point of the primary is
+ // that it takes the surplus, so it has to be the biggest single share at the current
+ // container width — asserted against the real content width rather than a ratio.
+ for (const [label, columns] of Object.entries(APPS_TABLE_COLUMN_LEDGERS)) {
+ const claimed = columns.reduce((sum, c) => sum + (c ?? 0), 0);
+ const primaryPct = 100 - claimed;
+ const widest = Math.max(...columns.map((c) => c ?? 0));
+ expect(primaryPct, `${label}: the primary column's share`).toBeGreaterThan(widest);
+ }
+ });
+});
+
+describe('🔴 every HEADED table under /apps is enumerated, not remembered', () => {
+ /**
+ * 🔴 THIS REPLACED A HAND-WRITTEN LIST OF FOUR FILES, AND THE REPLACEMENT IS THE FIX
+ * FOR A REAL DEFECT THIS PR SHIPPED. The first pass removed `/apps/review`'s 1368 body
+ * cap and gave ledgers to the two tables somebody had in mind. That route renders FOUR
+ * tables, and the cap was the only thing holding the other two down: measured on
+ * `ActivePreviewsPanel` at 1440 → 2560, the gap between a row's slug and its "Tear
+ * down" button grew 817.36 → 1381.23 — half the container delta, i.e. exactly the
+ * defect this module exists to remove, newly introduced by removing its workaround.
+ *
+ * A list of files nobody derives cannot notice a table it never mentioned. So the set
+ * is DERIVED by parsing every component under the two directories, and anything without
+ * a ledger has to be EXEMPTED BY NAME with a reason.
+ *
+ * ⚠️ WHAT THIS GUARD DOES NOT COVER. This list was written as though it were closed
+ * ("stated rather than implied") and it was not — an audit found two more escapes, which
+ * is exactly the shape of over-claim the guard itself exists to stop. It is a list of the
+ * ones known TODAY, not a proof of completeness:
+ * · a table with NO header row is out of scope (there is no column ledger to attach
+ * and no header cell to count against one) — `AppAnalyticsPanel`'s two key/value
+ * tables are the live examples;
+ * · it reads `src/components/Apps` and `src/components/AppBlocks` only, so a table
+ * defined outside those directories and rendered on an apps route is invisible;
+ * · the walk matches the literal tag `Table`, so a NEW file that imports it under
+ * another name (`import { Table as DataTable }`) is not seen;
+ * · `findThead` walks JSX DESCENDANTS, so a NEW file whose header row is extracted
+ * into a sibling component renders a `` this walk does not classify as headed.
+ *
+ * The last two are escapes for a NEW file only: an existing table cannot use them,
+ * because the SITES ledger below is asserted as an exact list and reds on a SHRINK as
+ * well as a growth. Both are low-realism and neither is fixed here — 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.
+ */
+ const SCAN_DIRS = ['src/components/Apps', 'src/components/AppBlocks'];
+
+ /** Tags that are a header CELL. `SortableTh` is a real one, not a naming convention. */
+ const HEADER_TAGS = new Set(['Table.Th', 'SortableTh']);
+
+ /**
+ * Headed tables that legitimately carry no ledger, each with the reason.
+ *
+ * 🔴 VERIFIED, NOT TRUSTED — the same rule the chrome allowlist in
+ * `appsPageWidths.test.ts` holds itself to. Every entry here claims the component is
+ * not rendered on any route, and the test below re-derives that by searching the whole
+ * of `src` for a JSX render of it. Adding a name cannot silence the guard.
+ */
+ /**
+ * Headed tables that legitimately carry no ledger, each with the reason AND the way that
+ * reason is re-derived. Two kinds, because two different things can make a ledger wrong:
+ *
+ * `unrendered` — nothing renders the component, so there is no surface to lay out.
+ * Verified by searching the whole of `src` for a JSX render of it.
+ * `no-surplus` — the table is rendered, but its natural layout already fills the
+ * container at the NARROW end, so every ledger either wraps a cell
+ * there or reproduces natural layout at the wide end. Verified by a
+ * named geometry arm that keeps measuring it.
+ *
+ * 🔴 VERIFIED, NOT TRUSTED — the same rule the chrome allowlist in
+ * `appsPageWidths.test.ts` holds itself to. An exemption keyed on a name is exactly where
+ * a live table would hide, so adding a name here cannot silence the guard: each kind has
+ * a check below that has to pass.
+ */
+ const EXEMPT: Record<
+ string,
+ | { kind: 'unrendered'; component: string; why: string }
+ | { kind: 'no-surplus'; component: string; why: string; geometryArm: string }
+ > = {
+ 'src/components/Apps/OffsiteReviewQueue.tsx#0': {
+ kind: 'unrendered',
+ component: 'OffsiteReviewQueue',
+ why: 'dead — superseded by the unified queue; nothing renders it (the LIVE table in this file is OffsiteReportsQueue, which does carry a ledger)',
+ },
+ 'src/components/Apps/MySubmissionsList.tsx#0': {
+ kind: 'unrendered',
+ component: 'MySubmissionsList',
+ why: 'dead — /apps/my-submissions merged into /apps/mine and 301s there',
+ },
+ 'src/components/Apps/OffsiteSubmissionsList.tsx#0': {
+ kind: 'unrendered',
+ component: 'OffsiteSubmissionsList',
+ why: 'dead — same merge as MySubmissionsList',
+ },
+ 'src/components/Apps/OffsiteReviewQueue.tsx#1': {
+ kind: 'no-surplus',
+ component: 'OffsiteReportsQueue',
+ why:
+ 'at 1200 its row wants App 240 + Reason 292 + Reporter 94 + Reported 133 + Status ' +
+ '86 + actions 414 = 1259px in 1168px of container, so something is under-served ' +
+ 'there whatever the split. Every candidate ledger was taller than natural at 1200 ' +
+ 'or clipped the lineClamp-ed details harder. See the note in appsWideLayout.tsx.',
+ geometryArm: 'the reports table is no worse than natural at every width',
+ },
+ 'src/components/Apps/AppActivityPanel.tsx#0': {
+ kind: 'no-surplus',
+ component: 'AppActivityPanel',
+ why:
+ 'its max-content sum (~735px) is the container content width AT 768, so there is no ' +
+ 'surplus to place at the narrow end. Two ledgers shipped and both regressed row ' +
+ 'height (48.09 and 64.89 against a natural 36.19); the only configuration that ' +
+ 'holds one line everywhere reproduces natural layout at 2560 to within ~15px on ' +
+ 'three of five columns. See the note in appsWideLayout.tsx.',
+ geometryArm: 'the activity table renders ONE LINE per cell at every width',
+ },
+ };
+
+ type TableSite = {
+ id: string;
+ file: string;
+ /** Is the FIRST renderable child an `AppsTableColgroup`? */
+ colgroupIsFirstChild: boolean;
+ /** Is there an `AppsTableColgroup` among the table's children at all? */
+ hasColgroup: boolean;
+ /** Ledger expressions named on the colgroup's `columns` prop. */
+ ledgerRefs: string[];
+ /** Header cells that always render. */
+ headerCellsAlways: number;
+ /** …plus the ones inside a `{cond && …}` / ternary. */
+ headerCellsTotal: number;
+ };
+
+ const isElement = (n: ts.Node): n is ts.JsxElement | ts.JsxSelfClosingElement =>
+ ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n);
+ const tagOf = (n: ts.JsxElement | ts.JsxSelfClosingElement, src: ts.SourceFile) =>
+ (ts.isJsxElement(n) ? n.openingElement.tagName : n.tagName).getText(src);
+
+ /** Every `` element in `file` that has a header row, with what it carries. */
+ function tableSites(file: string): TableSite[] {
+ const abs = repoFile(file);
+ const source = ts.createSourceFile(
+ abs,
+ fs.readFileSync(abs, 'utf8'),
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TSX
+ );
+ const sites: TableSite[] = [];
+ let seen = 0;
+
+ /** Count header cells under `node`, split by whether they are inside an expression. */
+ const countHeaders = (node: ts.Node): { always: number; total: number } => {
+ let always = 0;
+ let total = 0;
+ const walkRow = (row: ts.JsxElement) => {
+ for (const child of row.children) {
+ if (isElement(child) && HEADER_TAGS.has(tagOf(child, source))) {
+ always += 1;
+ total += 1;
+ } else if (ts.isJsxExpression(child) && child.expression) {
+ // A conditional header cell — counted toward the TOTAL only.
+ const inner = (n: ts.Node): void => {
+ if (isElement(n) && HEADER_TAGS.has(tagOf(n, source))) total += 1;
+ ts.forEachChild(n, inner);
+ };
+ inner(child.expression);
+ }
+ }
+ };
+ const findRows = (n: ts.Node): void => {
+ if (ts.isJsxElement(n) && tagOf(n, source) === 'Table.Tr') walkRow(n);
+ ts.forEachChild(n, findRows);
+ };
+ findRows(node);
+ return { always, total };
+ };
+
+ const visit = (node: ts.Node): void => {
+ if (ts.isJsxElement(node) && tagOf(node, source) === 'Table') {
+ // A header row group is what makes a table in scope.
+ let thead: ts.Node | null = null;
+ const findThead = (n: ts.Node): void => {
+ if (thead) return;
+ if (ts.isJsxElement(n) && tagOf(n, source) === 'Table.Thead') thead = n;
+ else ts.forEachChild(n, findThead);
+ };
+ node.children.forEach(findThead);
+ if (thead) {
+ const id = `${file}#${seen}`;
+ seen += 1;
+ // 🔴 THE FIRST *RENDERABLE* CHILD. JSX text is whitespace and a `{/* … */}`
+ // comment is a JsxExpression with no expression — neither renders, and treating
+ // either as "the first child" would fail every correctly-written call site,
+ // all of which explain themselves in a comment above the colgroup.
+ const renderable = node.children.filter(
+ (c) => isElement(c) || (ts.isJsxExpression(c) && !!c.expression)
+ );
+ const first = renderable[0];
+ const colgroups = node.children.filter(
+ (c): c is ts.JsxSelfClosingElement | ts.JsxElement =>
+ isElement(c) && tagOf(c, source) === 'AppsTableColgroup'
+ );
+ const attrs = colgroups[0]
+ ? ts.isJsxElement(colgroups[0])
+ ? colgroups[0].openingElement.attributes
+ : colgroups[0].attributes
+ : null;
+ const columnsText =
+ attrs?.properties
+ .filter(ts.isJsxAttribute)
+ .find((a) => a.name.getText(source) === 'columns')
+ ?.initializer?.getText(source) ?? '';
+ const { always, total } = countHeaders(thead);
+ sites.push({
+ id,
+ file,
+ colgroupIsFirstChild:
+ !!first && isElement(first) && tagOf(first, source) === 'AppsTableColgroup',
+ hasColgroup: colgroups.length > 0,
+ ledgerRefs: [...new Set(columnsText.match(/APPS_[A-Z0-9_]+(?:\.\w+)?/g) ?? [])],
+ headerCellsAlways: always,
+ headerCellsTotal: total,
+ });
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(source);
+ return sites;
+ }
+
+ /** Every `.tsx` under the scan dirs that is not itself a test. */
+ function scanFiles(): string[] {
+ const out: string[] = [];
+ for (const dir of SCAN_DIRS) {
+ const abs = repoFile(dir);
+ const walk = (d: string) => {
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
+ const full = path.join(d, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name !== '__tests__') walk(full);
+ continue;
+ }
+ if (!entry.name.endsWith('.tsx') || entry.name.includes('.test.')) continue;
+ out.push(path.relative(repoFile('.'), full).replace(/\\/g, '/'));
+ }
+ };
+ walk(abs);
+ }
+ return out.sort();
+ }
+
+ const SITES = scanFiles().flatMap((f) => tableSites(f));
+
+ test('🔴 the headed-table set is exactly this (fails when it GROWS or SHRINKS)', () => {
+ // A LEDGER, not a floor. A walk that found nothing makes every loop below pass having
+ // checked nothing — the failure the hand-written file list had, arrived at by a
+ // different route — and a floor cannot see a NEW table either, which is the whole
+ // reason this describe block exists. Adding a headed table to these directories is
+ // meant to fail here and be classified deliberately.
+ expect(scanFiles().length).toBeGreaterThan(40);
+ expect(SITES.map((s) => s.id)).toEqual([
+ 'src/components/AppBlocks/RevenuePanel.tsx#0',
+ 'src/components/Apps/ActivePreviewsPanel.tsx#0',
+ 'src/components/Apps/AppActivityPanel.tsx#0',
+ 'src/components/Apps/AppListingsModerationTable.tsx#0',
+ 'src/components/Apps/MyAppsBody.tsx#0',
+ 'src/components/Apps/MySubmissionsList.tsx#0',
+ // 🔴 TWO ENTRIES FOR ONE FILE, and this is the row that proves the walk is
+ // per-TABLE rather than per-file: `#0` is the dead `OffsiteReviewQueue` and `#1` is
+ // the live `OffsiteReportsQueue`. A per-file guard cannot express "one of these
+ // needs a ledger and the other does not", and an `indexOf`-based one would have
+ // graded the second table against the first one's colgroup.
+ 'src/components/Apps/OffsiteReviewQueue.tsx#0',
+ 'src/components/Apps/OffsiteReviewQueue.tsx#1',
+ 'src/components/Apps/OffsiteSubmissionsList.tsx#0',
+ 'src/components/Apps/ReportTabs.tsx#0',
+ 'src/components/Apps/UnifiedReviewList.tsx#0',
+ ]);
+ });
+
+ test('🔴 every headed table carries a ledger, or is exempted by name', () => {
+ const offenders = SITES.filter((s) => !s.hasColgroup && !EXEMPT[s.id]).map(
+ (s) => `${s.id} (${s.headerCellsTotal} header cells, no )`
+ );
+ expect(
+ offenders,
+ 'A table with a header row on an /apps surface has no column ledger. Give it one in ' +
+ '~/components/Apps/appsWideLayout, or add it to EXEMPT with the reason it needs none.'
+ ).toEqual([]);
+ });
+
+ test("🔴 the colgroup is the table's FIRST CHILD — not merely somewhere in the file", () => {
+ // 🔴 THIS REPLACED AN `indexOf` COMPARISON, WHICH WAS SPELLED RATHER THAN STRUCTURAL,
+ // in two ways that both pass it: hoisting the colgroup OUTSIDE the `` leaves it
+ // earlier in the file than the head (green, and it bounds nothing), and `indexOf`
+ // reads the FIRST occurrence in the file, so a second table in the same file was
+ // graded against the first one's colgroup. Asking the AST which element is the table's
+ // own first child cannot be satisfied by either.
+ const offenders = SITES.filter((s) => s.hasColgroup && !s.colgroupIsFirstChild).map(
+ (s) => `${s.id} (renders a colgroup, but it is not the table's first child)`
+ );
+ expect(offenders).toEqual([]);
+ });
+
+ test('🔴 every EXEMPT table exists, and its REASON is re-derived (not believed)', () => {
+ const names = Object.keys(EXEMPT);
+ expect(names.length, 'the exempt list is empty — nothing to verify').toBeGreaterThan(0);
+ const offenders: string[] = [];
+ let unrenderedChecked = 0;
+ let noSurplusChecked = 0;
+ for (const id of names) {
+ const entry = EXEMPT[id];
+ expect(
+ SITES.some((s) => s.id === id),
+ `${id} is exempted but no such table exists`
+ ).toBe(true);
+ if (entry.kind === 'unrendered') {
+ unrenderedChecked += 1;
+ const hits = renderSitesOf(entry.component);
+ if (hits.length > 0) {
+ offenders.push(
+ `${entry.component} is exempted as UNRENDERED but is rendered by ${hits.join(', ')}`
+ );
+ }
+ } else {
+ noSurplusChecked += 1;
+ // 🔴 A `no-surplus` TABLE **IS** RENDERED — that is the whole difference — so the
+ // render search would reject it. What has to hold instead is that the measurement
+ // the exemption rests on is still being TAKEN: a named geometry arm covering it.
+ // Without this the reason decays into prose the moment the table's content changes.
+ const geometry = srcOf('src/components/Apps/AppsWideLayout.geometry.test.tsx');
+ if (!geometry.includes(entry.geometryArm)) {
+ offenders.push(
+ `${entry.component} is exempted as NO-SURPLUS but its geometry arm ` +
+ `"${entry.geometryArm}" is not in AppsWideLayout.geometry.test.tsx — the ` +
+ 'exemption would be an unmeasured claim'
+ );
+ }
+ if (renderSitesOf(entry.component).length === 0) {
+ offenders.push(
+ `${entry.component} is exempted as NO-SURPLUS but nothing renders it — it is ` +
+ 'UNRENDERED, and the two kinds must not be conflated'
+ );
+ }
+ }
+ }
+ expect(offenders).toEqual([]);
+ // Guard-the-guard: both branches must actually be exercised, or one of them is dead
+ // code that would wave through the case it exists for.
+ expect(unrenderedChecked, 'no `unrendered` exemption was checked').toBeGreaterThan(0);
+ expect(noSurplusChecked, 'no `no-surplus` exemption was checked').toBeGreaterThan(0);
+ });
+
+ test('🔴 POSITIVE CONTROL — the render search can find a component that IS rendered', () => {
+ // Without this, `renderSitesOf` returning `[]` for every exempt entry is
+ // indistinguishable from a search wired to nothing, and the allowlist would be
+ // verified by a function that always says yes.
+ expect(renderSitesOf('ActivePreviewsPanel').length).toBeGreaterThan(0);
+ expect(renderSitesOf('AppActivityPanel').length).toBeGreaterThan(0);
+ expect(renderSitesOf('NoSuchComponentAnywhere')).toEqual([]);
+ });
+
+ test('🔴 the ledger LENGTH matches the header cells the table actually renders', () => {
+ // 🔴 THE TITLE USED TO CLAIM THIS AND THE BODY ASSERTED `.length` AGAINST A LITERAL —
+ // a docstring naming a RELATIONSHIP over a body inspecting ONE SIDE. Measured then:
+ // adding a `` without touching the ledger left `AppListingsModerationTable`
+ // and `RevenuePanel` green at BOTH tiers, and `[null,5,5,5,5]` satisfied every test.
+ // Now the table is read.
+ //
+ // A table with a CONDITIONAL header cell has two shapes, so it names two ledgers: the
+ // shortest must equal the always-rendered count and the longest the total.
+ const offenders: string[] = [];
+ let checked = 0;
+ for (const site of SITES) {
+ if (!site.hasColgroup) continue;
+ const lengths = site.ledgerRefs.map((ref) => {
+ const [name, key] = ref.split('.');
+ const root = (LEDGER_EXPORTS as Record)[name];
+ const value = key ? (root as Record)?.[key] : root;
+ return Array.isArray(value) ? value.length : NaN;
+ });
+ if (lengths.length === 0 || lengths.some((n) => Number.isNaN(n))) {
+ offenders.push(
+ `${site.id}: could not resolve its ledger(s) [${site.ledgerRefs.join(', ')}]`
+ );
+ continue;
+ }
+ checked += 1;
+ const min = Math.min(...lengths);
+ const max = Math.max(...lengths);
+ if (min !== site.headerCellsAlways) {
+ offenders.push(
+ `${site.id}: shortest ledger is ${min} but the table always renders ` +
+ `${site.headerCellsAlways} header cell(s) [${site.ledgerRefs.join(', ')}]`
+ );
+ }
+ if (max !== site.headerCellsTotal) {
+ offenders.push(
+ `${site.id}: longest ledger is ${max} but the table renders at most ` +
+ `${site.headerCellsTotal} header cell(s) [${site.ledgerRefs.join(', ')}]`
+ );
+ }
+ }
+ expect(offenders).toEqual([]);
+ // Guard-the-guard: an empty offender list is indistinguishable from a loop that
+ // resolved no ledgers at all.
+ // Six ledgered tables today — `AppActivityPanel` and `OffsiteReportsQueue` gave theirs
+ // up as `no-surplus` exemptions, so this floor moved DOWN deliberately rather than
+ // drifting. It exists so an empty offender list cannot mean "resolved nothing".
+ expect(checked, 'no table had its ledger resolved').toBeGreaterThanOrEqual(6);
+ });
+
+ test('🔴 the header-cell counter can SEE a conditional column (negative control)', () => {
+ // The two-shape tables are the only ones where `always` and `total` differ, so if the
+ // conditional branch of the counter were dead every assertion above would still pass.
+ const twoShaped = SITES.filter((s) => s.headerCellsTotal > s.headerCellsAlways);
+ expect(twoShaped.map((s) => s.id).sort()).toEqual([
+ 'src/components/AppBlocks/RevenuePanel.tsx#0',
+ 'src/components/Apps/UnifiedReviewList.tsx#0',
+ ]);
+ for (const s of twoShaped) expect(s.ledgerRefs.length).toBe(2);
+ });
+
+ test('🔴 /apps/installed uses the card GRID *on the installed-apps list*, and no longer caps its body', () => {
+ // 🔴 THE testId IS LOAD-BEARING, NOT DECORATION. This asserted `/`, i.e. undoing the headline fix on the one route whose 640px
+ // defect was measured, passed unit 124/124 and geometry 27/27.
+ const src = codeOf('src/pages/apps/installed.tsx');
+ expect(src).toMatch(/]*\bmeasure=/s);
+ });
+
+ test('🔴 /apps/review no longer caps its body either', () => {
+ const src = codeOf('src/pages/apps/review.tsx');
+ expect(src).not.toMatch(/]*\bmeasure=/s);
+ // …and its queue table is the one carrying the columns instead.
+ expect(codeOf('src/components/Apps/UnifiedReviewList.tsx')).toMatch(/ {
+ // Every `not.toMatch` above is satisfied by a stripper that returned an EMPTY string,
+ // and every `toMatch` by one that returned the file UNCHANGED. Both directions are
+ // pinned on a synthetic input carrying the exact hazard — the element named inside a
+ // comment, before the real one — rather than on whatever a real file happens to say
+ // today, which is a fixture that quietly stops testing anything when the prose moves.
+ const synthetic = [
+ 'const a = 1;',
+ '/* the colgroup must precede , always */',
+ ' ',
+ ' ',
+ ].join('\n');
+ const stripped = stripBlockComments(synthetic);
+ expect(synthetic.indexOf(' {
+ test('one column at the old container width, two at the current one', () => {
+ expect(APPS_LEGACY_CONTENT_WIDTH).toBe(1888);
+ expect(APPS_FULL_MEASURE_CONTENT_WIDTH).toBe(2528);
+ expect(appsCardGridColumnsAt(APPS_LEGACY_CONTENT_WIDTH)).toBe(1);
+ expect(appsCardGridColumnsAt(APPS_FULL_MEASURE_CONTENT_WIDTH)).toBe(2);
+ });
+
+ test('never fewer than one, however narrow', () => {
+ // `floor()` on a phone width is 0; a grid with zero tracks renders nothing.
+ expect(appsCardGridColumnsAt(390)).toBe(1);
+ expect(appsCardGridColumnsAt(0)).toBe(1);
+ });
+
+ test("🔴 the ladder is IDENTICAL at both shipped gaps (F8's equivalence, asserted)", () => {
+ // 🔴 THE CITATION EXISTED BEFORE THE ASSERTION DID. `appsWideLayout.tsx` said this was
+ // "pinned in __tests__/appsWideLayout.test.ts" and `appsPageWidths.ts` said it was
+ // "asserted rather than assumed", while every call site here used the DEFAULT gap —
+ // measured, deleting `gap={12}` from the Hidden tab left this file 31/31 green. The
+ // claim was arithmetically true and the citation was false, which is the same shape as
+ // the findings that produced this paragraph.
+ const HIDDEN_TAB_GAP = 12;
+ expect(HIDDEN_TAB_GAP).not.toBe(APPS_CARD_LIST_GAP);
+ for (const width of [APPS_LEGACY_CONTENT_WIDTH, APPS_FULL_MEASURE_CONTENT_WIDTH, 1408, 2416]) {
+ expect(
+ appsCardGridColumnsAt(width, APPS_CARD_LIST_MIN_COLUMN, HIDDEN_TAB_GAP),
+ `content ${width} at gap ${HIDDEN_TAB_GAP}`
+ ).toBe(appsCardGridColumnsAt(width, APPS_CARD_LIST_MIN_COLUMN, APPS_CARD_LIST_GAP));
+ }
+ // Guard-the-guard: a gap large enough to change the ladder MUST make them disagree, or
+ // the loop above is satisfied by an implementation that ignores its `gap` argument.
+ expect(appsCardGridColumnsAt(2528, APPS_CARD_LIST_MIN_COLUMN, 400)).not.toBe(
+ appsCardGridColumnsAt(2528, APPS_CARD_LIST_MIN_COLUMN, APPS_CARD_LIST_GAP)
+ );
+ });
+
+ test('🔴 the Hidden tab really does pass its own gap', () => {
+ // The other half: the equivalence above is about numbers, this is about the call site
+ // the numbers are for. Deleting the prop is what the arithmetic cannot see.
+ const src = codeOf('src/pages/apps/installed.tsx');
+ expect(src).toMatch(/ {
+ // Feed a min/gap pair no real call uses and watch the answer move, so the two rungs
+ // above cannot be satisfied by a function that returns constants.
+ expect(appsCardGridColumnsAt(1000, 240, 10)).toBe(4);
+ expect(appsCardGridColumnsAt(1000, 100, 0)).toBe(10);
+ });
+});
diff --git a/src/components/Apps/appsPageWidths.ts b/src/components/Apps/appsPageWidths.ts
index 405c378fe1..ca0f53913a 100644
--- a/src/components/Apps/appsPageWidths.ts
+++ b/src/components/Apps/appsPageWidths.ts
@@ -42,6 +42,15 @@
* `max-width` with responsive horizontal padding and auto margins. So the apps
* container fills a 1440px viewport edge-to-edge (minus padding) and only stops
* growing past 1920 — it is NOT a fixed 1920px box.
+ *
+ * 🔴 A MEASURE IS A **BAND**, NOT ALWAYS A NUMBER — and that is the second pass.
+ * The container went 1920 → 2560 and the routes that take no measure grew with it,
+ * but a fixed measure did not: `/apps/submit` rendered the same 1068px of form on a
+ * 1440 monitor and on a 2560 one. Prose has a readability CEILING, not a readability
+ * WIDTH, so the narrowed routes now carry a {@link AppsMeasureBand} — a floor, a
+ * ceiling, and the share of the container they track between the two. See
+ * {@link appsMeasureCss} for the CSS it becomes and why the floor is the number the
+ * page used to render at.
*/
/**
@@ -57,6 +66,118 @@
*/
export const APPS_CONTAINER_GUTTER = 32;
+/**
+ * The container width every `/apps/*` route rendered in BEFORE the ultrawide pass,
+ * kept as a named literal because two of this module's guards are about NOT MOVING
+ * anything below it.
+ *
+ * It is the reference point for {@link AppsMeasureBand}'s floor: a band must still
+ * resolve to its `min` at `1920 − 32 = 1888` of content, so widening a measured
+ * route is strictly an ADDITION on screens wider than the old cap, never a change to
+ * what a 1440 or 1920 monitor already showed.
+ */
+export const APPS_LEGACY_CONTAINER_WIDTH = 1920;
+
+/**
+ * A CONTENT MEASURE that GROWS WITH THE CONTAINER, between a floor and a ceiling.
+ *
+ * `min` is the width the route rendered at before it had a band — so nothing moves
+ * on the screens that were already measured. `max` is the width past which the
+ * surface stops being better for being wider (a prose measure, or a two-column split
+ * whose main column would itself become unreadable). `grow` is the PERCENTAGE OF THE
+ * CONTAINER'S CONTENT WIDTH the measure tracks between them.
+ *
+ * 🔴 `grow` IS WHAT MAKES THE BAND A RAMP RATHER THAN A SECOND FIXED NUMBER. With
+ * `100%` the box would jump straight to `max` at every desktop width — i.e. a silent
+ * re-decision of what a 1440 monitor shows, dressed up as an ultrawide change. The
+ * two `grow` values in this file are chosen so that each band is EXACTLY at its `min`
+ * at {@link APPS_LEGACY_CONTAINER_WIDTH}'s content width and EXACTLY at its `max` by
+ * the current container's; both halves are pinned in `__tests__/appsPageWidths.test.ts`
+ * as arithmetic rather than asserted here in prose.
+ */
+export type AppsMeasureBand = {
+ /** px floor — the width this route rendered at before it had a band. */
+ readonly min: number;
+ /** px ceiling — the width past which more space stops helping this surface. */
+ readonly max: number;
+ /** % of the container's CONTENT width the measure tracks between floor and ceiling. */
+ readonly grow: number;
+};
+
+/** A route's content measure: a fixed px width, or a {@link AppsMeasureBand}. */
+export type AppsMeasure = number | AppsMeasureBand;
+
+/** Narrow an {@link AppsMeasure} to the band case. */
+export function isAppsMeasureBand(measure: AppsMeasure): measure is AppsMeasureBand {
+ return typeof measure === 'object' && measure !== null;
+}
+
+/**
+ * The value handed to the measure box's `maw`.
+ *
+ * A number passes straight through (Mantine converts it to `rem`). A band becomes a
+ * `clamp()`, and the middle term is a PERCENTAGE deliberately: a percentage in
+ * `max-width` resolves against the CONTAINING BLOCK, which here is the shared
+ * `Container`'s content box — so the ramp is bounded by the container's own cap and
+ * keeps working unchanged on a monitor wider than it. `vw` would not: it keeps
+ * growing past 2560 while the container has stopped, so the band would reach its
+ * ceiling on a screen where the container never widened.
+ *
+ * 🔴 Mantine's `rem()` passes a `clamp(` string through untouched (it early-returns on
+ * that prefix), so this lands in the DOM verbatim rather than being re-scaled.
+ */
+export function appsMeasureCss(measure: AppsMeasure): number | string {
+ if (!isAppsMeasureBand(measure)) return measure;
+ return `clamp(${measure.min}px, ${measure.grow}%, ${measure.max}px)`;
+}
+
+/**
+ * The narrowest column a `/apps/*` CARD LIST may be laid out in before it starts
+ * putting cards SIDE BY SIDE instead of stretching each one across the container.
+ *
+ * 🔴 THIS EXISTS FOR `/apps/installed`, AND FOR ONE MEASURED DEFECT. That page is a
+ * `Stack` of full-width cards whose header rows are
+ * `` — content on the left, the control
+ * that acts on it on the right. Raising the container to 2560 therefore moved the
+ * Manage / Restore button **640px further from the app name it belongs to**, because the
+ * row's left cell is `flex: 1` and the surplus lands inside it as dead space.
+ *
+ * ⚠️ THREE ROWS, NOT FOUR, and the correction is worth keeping because the fourth is a
+ * reminder that the SHAPE is not the defect. The rows are in `PinnedInstallRow`,
+ * `InstalledAppCard` and `HiddenBlocksPanel` (named rather than cited by line number —
+ * this file's own edits moved every one of the line numbers first quoted here).
+ * `ScopeGrantsPanel`'s row is `justify="space-between"` too and NOTHING moved in it: it
+ * has a SINGLE flex child and no control, so there are no two ends for the space to open
+ * between. A grep for the justify prop over-counts; what matters is content-plus-control.
+ *
+ * A body cap would fix the gap by refusing the width, which is the thing the container
+ * pass exists to stop doing. So the width is spent on COLUMNS instead: the card list is
+ * a `repeat(auto-fill, minmax(…, 1fr))` grid, and 1200 is picked so the ladder steps
+ * exactly where the surplus appeared —
+ *
+ * content 1888 (the OLD 1920 container): `floor((1888 + 16) / (1200 + 16))` = **1 column**
+ * content 2528 (the CURRENT container): `floor((2528 + 16) / (1200 + 16))` = **2 columns**
+ *
+ * — i.e. nothing a 1440 or 1920 monitor shows changes, and the second column arrives at
+ * 2416 of content. Both rungs are pinned in `__tests__/appsWideLayout.test.ts`; the
+ * rendered consequence (the gap does not grow) is in
+ * `AppsWideLayout.geometry.test.tsx`.
+ *
+ * 🔴 "NOTHING CHANGES" IS A CLAIM ABOUT SPACING AS WELL AS COLUMN COUNT, which is why
+ * `AppsCardGrid` takes a `gap`. The lists it replaced did not share one: the Hidden tab
+ * was `` (12px), the other two `md` (16px). Defaulting all three to 16
+ * would have moved 4px on a tab a 1440 monitor shows — small, but the sentence above
+ * says NOTHING, and a claim that is 99% true is the kind nobody re-checks. Both gaps
+ * produce the SAME rungs at both container widths, so carrying the original number is
+ * free; that equivalence is asserted rather than assumed.
+ */
+export const APPS_CARD_LIST_MIN_COLUMN = 1200;
+
+/** The DEFAULT gap between card-grid tracks, px — Mantine's `md` spacing, stated as a
+ * number because the column arithmetic above needs it. A list that used a different
+ * `Stack` gap passes its own; see `AppsCardGrid`'s `gap` prop. */
+export const APPS_CARD_LIST_GAP = 16;
+
/**
* The ONE container width every `/apps/*` route renders in.
*
@@ -85,13 +206,21 @@ export const APPS_CONTAINER_GUTTER = 32;
* genuinely edge-to-edge. Past it the pages centre again, which is the same
* behaviour 1920 gave a 2560 monitor.
*
- * ⚠️ SIBLING ROUTES GET WIDER TOO, AND SOME OF THEM DO NOT YET SPEND IT. Every route
- * in {@link APPS_FULL_MEASURE_PAGES} — `/apps/installed`, `/apps/mine`,
- * `/apps/revenue`, `/apps/review/[publishRequestId]` — takes no measure, so its table
- * now lays out at up to 2528px of content (less the scroll container's scrollbar on
- * platforms that reserve one). They are correct and unclipped there, but a table that
- * could not spend 1888 cannot spend 2528 either; making those tables use
- * the space is a deliberate follow-up, not part of this change.
+ * ✅ THE SIBLING ROUTES NOW SPEND IT — this note used to say they did not. Every route
+ * in {@link APPS_FULL_MEASURE_PAGES} lays out at up to 2528px of content, and the
+ * surplus goes into COLUMN WIDTHS rather than padding: the tables carry a proportional
+ * `` whose PRIMARY column takes the slack, and `/apps/installed`'s card list
+ * steps to a second grid column. Both mechanisms live in
+ * `~/components/Apps/appsWideLayout` — read {@link APPS_CARD_LIST_MIN_COLUMN} for the
+ * measured 640px dead-gap defect the card half fixes.
+ *
+ * 🔴 `/apps/review` IS IN THAT LIST NOW, AND ITS 1368 CAP IS DELETED. It was the one
+ * route whose measure existed to work around this: four short columns could not spend
+ * the container, so the table distributed the surplus as padding and the Review button
+ * receded from the row it acts on. Capping the page refused the width to avoid
+ * mis-spending it; the columns are proportional now, so the workaround would only be
+ * hiding the fix. There is no `APPS_NARROW_TABLE_MEASURE` any more — do not
+ * reintroduce one for a table that reads too wide; give it a ` `.
*
* 🔴 IT IS ALSO THE CHROME'S WIDTH, on every route, which is the point of this
* module. Do not reintroduce a per-page `Container size=` — `AppsPageLayout` no
@@ -100,44 +229,30 @@ export const APPS_CONTAINER_GUTTER = 32;
export const APPS_PAGE_CONTAINER_WIDTH = 2560;
/**
- * The READABLE measure — single-column form/detail surfaces where line length, not
- * available space, is the constraint. A submit wizard or a listing editor stretched
- * to the full container (2528px of content today, less any reserved scrollbar) puts
- * prose and form rows on an
+ * The READABLE measure — single-column form/prose surfaces where line length, not
+ * available space, is the constraint. A submit wizard or a listing editor stretched to
+ * the full container (2528px of content today) puts prose and form rows on an
* unreadable measure.
*
- * `1068 = 1100 − 32`: the content width these pages rendered when they passed
+ * A BAND since the ultrawide pass, not the fixed 1068 it was:
+ *
+ * `min: 1068 = 1100 − 32` — the content width these pages rendered when they passed
* `size={1100}`. 1100 was chosen as wider than the `sm` (620) / `md` (800) / `lg`
* (990) tokens these pages used before it — the forms have two-column rows and media
- * grids that were cramped at 620 — while staying inside a comfortable measure.
+ * grids that were cramped at 620 — while staying inside a comfortable measure. It is
+ * the FLOOR rather than the width, so nothing a 1440 or 1920 monitor shows moves.
+ *
+ * `max: 1368` — the number the deleted narrow-table class carried, and it is reused
+ * rather than invented for the reason that class recorded: 1400 (1368 of content) was
+ * "wider than the readable/form width while stopping short of the width where the
+ * row's two ends stop reading as one row". That judgement is about a two-ended ROW,
+ * which is exactly what these pages' form rows are.
+ *
+ * `grow: 55` — see {@link AppsMeasureBand}. 55% of 1888 (the old container's content)
+ * is 1038 ≤ 1068, so the floor still wins there; 55% of 2528 is 1390 ≥ 1368, so the
+ * ceiling is reached inside the current container. Both are asserted arithmetically.
*/
-export const APPS_READABLE_MEASURE = 1068;
-
-/**
- * The NARROW-TABLE measure — a table surface with few, short columns, where the full
- * container is not "full width" but "stretched".
- *
- * `/apps/review` is the case this exists for. It renders FOUR narrow columns (Kind /
- * App / Submitter / Submitted) plus a Review button. At the full container width the
- * columns cannot spend the space, so the table distributes it as padding: measured at
- * the then-1888px content width, Submitter grew to ~380px to hold a short username and
- * a large dead gap opened between the last column and the Review button, which is the
- * action the moderator is actually aiming at. Raising the container to 2560 (2528 of
- * content, less any reserved scrollbar) makes that worse, not better — which is why this class exists rather than
- * tracking the container.
- *
- * `1368 = 1400 − 32`: the content width the page rendered at `size={1400}`. 1400 was
- * picked over 1200 to keep the page wider than the readable/form width while stopping
- * short of the width where the row's two ends stop reading as one row.
- *
- * 🔴 A DISTINCT CLASS, deliberately — not a one-off number. The module's rule is "a
- * page joins a class, or the class list grows on purpose"; the guard in
- * `__tests__/appsPageWidths.test.ts` enumerates the classes as literals, so adding a
- * fourth bespoke measure still fails there first. `/apps/mine` is NOT moved here: its
- * table has a measured 1424px scroll floor, so the full container is load-bearing for
- * it in a way it is not for `/apps/review`.
- */
-export const APPS_NARROW_TABLE_MEASURE = 1368;
+export const APPS_READABLE_MEASURE: AppsMeasureBand = { min: 1068, max: 1368, grow: 55 };
/**
* The TWO-COLUMN DETAIL measure — a detail page laid out as a main column plus a
@@ -146,23 +261,33 @@ export const APPS_NARROW_TABLE_MEASURE = 1368;
* `/apps/store-preview/[slug]` is the case this exists for. It is a deliberate port
* of the MODEL DETAIL page's layout (`` in
* `src/pages/models/[id]/[[...slug]].tsx`) — the same `ContainerGrid2` with the same
- * `{ base: 12, sm: 7, md: 8 }` / `{ base: 12, sm: 5, md: 4 }` spans — so it takes
- * that page's content measure rather than a number of its own.
+ * `{ base: 12, sm: 7, md: 8 }` / `{ base: 12, sm: 5, md: 4 }` spans.
*
- * `1288 = 1320 − 32`, and Mantine's `xl` container is 1320 border-box, so 1288 is
+ * `min: 1288 = 1320 − 32`, and Mantine's `xl` container is 1320 border-box, so 1288 is
* EXACTLY what the model detail page renders its content at. Stating the equivalence
* in content terms is what makes it true: `maw={1320}` here would have been 32px
- * wider than the page it claims to match.
+ * wider than the page it claims to match. Why not the READABLE floor it used to be: at
+ * 1068 the `md` split gives a ~340px right rail, narrower than the creator card +
+ * action card want, and the page reads as a squeezed single column with a sliver
+ * beside it.
*
- * Why not the READABLE measure it used to be: at 1068 the `md` split gives a ~340px
- * right rail, narrower than the creator card + action card want, and the page reads as
- * a squeezed single column with a sliver beside it. Why not the full container: the
- * left column is prose (a `CustomMarkdown` description), and 8/12 of the container's
- * content width is a ~1685px measure at today's 2560 (it was ~1250px at 1920) — the
- * exact thing {@link APPS_READABLE_MEASURE} exists to avoid, and the container getting
- * wider only widens the gap. At 1288 the left column is ~825px and the rail ~410px.
+ * `max: 1600` — DERIVED, not chosen. The ceiling on this page is the LEFT column: it
+ * is prose (a `CustomMarkdown` description) at the `md` 8/12 span, and the readable
+ * band's own floor is 1068. `8/12 × 1600 = 1066.67 ≤ 1068`, so 1600 is the widest this
+ * page can be while its markdown column stays inside the measure the readable class
+ * exists to hold. The rail grows from ~410px to ~533px across the band, which is the
+ * half the ultrawide pass was asked for. Letting it track the full container instead
+ * would put the description on a ~1685px measure — the exact thing
+ * {@link APPS_READABLE_MEASURE} exists to avoid.
+ *
+ * `grow: 65` — 65% of 1888 is 1227 ≤ 1288 (floor holds at the old container) and 65%
+ * of 2528 is 1643 ≥ 1600 (ceiling reached inside the current one).
*/
-export const APPS_TWO_COLUMN_DETAIL_MEASURE = 1288;
+export const APPS_TWO_COLUMN_DETAIL_MEASURE: AppsMeasureBand = {
+ min: 1288,
+ max: 1600,
+ grow: 65,
+};
/**
* CONTENT MEASURE per `/apps/*` route, keyed by the NEXT ROUTE PATHNAME (the
@@ -197,13 +322,6 @@ export const APPS_TWO_COLUMN_DETAIL_MEASURE = 1288;
* correctly.
*/
export const APPS_PAGE_MEASURES = {
- /**
- * NARROW TABLE, not full width. Four short columns (Kind / App / Submitter /
- * Submitted) cannot spend the container — see {@link APPS_NARROW_TABLE_MEASURE}.
- * The DETAIL route takes no measure at all: it renders side-by-side diff panels +
- * a live preview, which do use the space.
- */
- '/apps/review': APPS_NARROW_TABLE_MEASURE,
/**
* TWO-COLUMN DETAIL, not readable-single-column — the model-detail-page layout
* (main column + right rail), so it takes that page's content measure.
@@ -234,7 +352,7 @@ export const APPS_PAGE_MEASURES = {
'/apps/[appBlockId]/revenue': APPS_READABLE_MEASURE,
/** The developer get-started explainer — prose. */
'/apps/get-started': APPS_READABLE_MEASURE,
-} as const satisfies Record;
+} as const satisfies Record;
export type AppsMeasuredRoute = keyof typeof APPS_PAGE_MEASURES;
@@ -250,12 +368,18 @@ export type AppsMeasuredRoute = keyof typeof APPS_PAGE_MEASURES;
* 1424px. Giving it the readable measure would re-create the exact clip the wide
* width was introduced to fix; `__tests__/appsPageWidths.test.ts` pins the container
* against that floor.
+ *
+ * 🔴 `/apps/review` JOINED THIS LIST when its 1368 cap was deleted — see the note on
+ * {@link APPS_PAGE_CONTAINER_WIDTH}. Taking the full container is only correct for it
+ * because its queue table now carries `APPS_REVIEW_QUEUE_COLUMNS`; the two changes are
+ * one decision and reverting either alone re-opens the dead-gap defect.
*/
export const APPS_FULL_MEASURE_PAGES = [
'/apps',
'/apps/installed',
'/apps/mine',
'/apps/revenue',
+ '/apps/review',
'/apps/review/[publishRequestId]',
] as const;
diff --git a/src/components/Apps/appsWideLayout.tsx b/src/components/Apps/appsWideLayout.tsx
new file mode 100644
index 0000000000..1822eae691
--- /dev/null
+++ b/src/components/Apps/appsWideLayout.tsx
@@ -0,0 +1,450 @@
+import type { ReactNode } from 'react';
+import {
+ APPS_CARD_LIST_GAP,
+ APPS_CARD_LIST_MIN_COLUMN,
+ APPS_CONTAINER_GUTTER,
+ APPS_LEGACY_CONTAINER_WIDTH,
+ APPS_PAGE_CONTAINER_WIDTH,
+} from '~/components/Apps/appsPageWidths';
+
+/**
+ * HOW A `/apps/*` SURFACE SPENDS SURPLUS CONTAINER WIDTH.
+ *
+ * `~/components/Apps/appsPageWidths` decides how wide a route's body is allowed to be.
+ * This module decides what the body DOES with it, and it exists because the two are
+ * different questions that were answered as one: the ultrawide pass raised the shared
+ * container 1920 → 2560, and the routes that take no measure went from 1888 to 2528 of
+ * content without a single column getting wider.
+ *
+ * 🔴 THE DEFECT IS A GAP, NOT A CLIP. Nothing was cut off — a table's columns simply
+ * stayed at their content width and the table distributed the extra 640px as PADDING,
+ * which on a `space-between` row lands entirely between a row's content and the control
+ * that acts on it. Measured on `/apps/installed`, where THREE
+ * `Group justify="space-between" wrap="nowrap"` rows (in `PinnedInstallRow`,
+ * `InstalledAppCard` and `HiddenBlocksPanel`) 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 PAGE at 1368 — a workaround this module replaces, so that cap is deleted.
+ *
+ * TWO MECHANISMS, one per surface shape:
+ *
+ * · A TABLE takes {@link AppsTableColgroup}. Every column except the PRIMARY one gets
+ * a percentage width; the primary gets none, so CSS's automatic table layout hands
+ * it everything left over. That is why there is exactly one `null` in each ledger
+ * below — and WHICH column gets it is a decision, not a default; see the two cases.
+ * · A CARD LIST takes {@link AppsCardGrid}. Cards go SIDE BY SIDE once the container
+ * is wide enough for two of {@link APPS_CARD_LIST_MIN_COLUMN}, so the surplus buys
+ * a column instead of stretching one card across the screen.
+ *
+ * 🔴 NEITHER MECHANISM MAY BE REPLACED BY A BODY CAP. Refusing the width is what the
+ * container pass exists to stop doing, and a cap is invisible to every guard here —
+ * see `__tests__/appsPageWidths.test.ts` for the taxonomy pins that keep these routes
+ * in `APPS_FULL_MEASURE_PAGES`.
+ *
+ * 🔴 WHICH COLUMN IS PRIMARY IS A DECISION WITH TWO CASES, AND GETTING IT WRONG MAKES THE
+ * DEFECT WORSE RATHER THAN BETTER. The slack has to land somewhere; "the primary column
+ * absorbs it" only helps when that column can USE it.
+ *
+ * (a) A column with genuinely VARIABLE, long content — an app name, a free-text reason,
+ * an event detail — is primary. The slack becomes headroom for real content that
+ * would otherwise truncate.
+ * (b) A table where NO column has that — every cell a slug, a badge, a date, or a
+ * hard-CAPPED text — takes its LAST column as primary instead, so the slack TRAILS
+ * the row rather than splitting it. That column is usually the action column, and
+ * the reason it works is that a cell's content is left-aligned: the control sits at
+ * its LEFT edge, so the slack lands to the RIGHT of the button. ⚠️ THAT ONLY HOLDS
+ * IF THE CELL REALLY IS LEFT-ALIGNED — `OffsiteReportsQueue`'s action `Group` was
+ * `justify="flex-end"`, which pins the buttons to the table's right edge and turns
+ * case (b) into the defect. It is `flex-start` now; that is a no-op at every width
+ * where the column sits at min-content, i.e. everywhere it is not the primary.
+ *
+ * 🔴 "VARIABLE" MEANS THE CELL CAN ACTUALLY USE THE PIXELS — measure it, do not read the
+ * field name. Two ledgers were wrong on exactly this and both were caught by rendering:
+ * · `OffsiteReportsQueue`'s `Reason` looked like free text and is
+ * `lineClamp={2} style={{ maxWidth: 260 }}`. Measured 1440 → 2560 with it primary:
+ * the column went 587.73 → 1404.64 (+816.91) while the details box stayed **260 at
+ * both** — ~1145px of dead space inside the cell, i.e. padding relabelled.
+ * · `AppActivityPanel`'s `Detail` looked like the payload and is a fixed monospace ref.
+ * With it primary the column went 971.56 → 1744.34 (+772.78) while its glyph box
+ * stayed **151.72 at both**. The component's own comment said so; the ledger did not.
+ *
+ * `ActivePreviewsPanel` is why this is written down. Its first ledger made `App` primary
+ * on the general rule — and `App` is a short `{slug}`, so 49% of the table
+ * became dead space sitting *between* the slug and the "Tear down" button. Modelled
+ * against the measured no-ledger baseline that is a WIDER gap than doing nothing at all.
+ * Case (b) puts the same slack past the button, where nothing has to be scanned across it.
+ *
+ * 🔴 AND THE FIXED SHARES ARE SIZED TO CONTENT, NOT SPREAD TO FILL. A non-primary
+ * percentage that exceeds what its cell needs at the wide width is padding again, just
+ * relabelled — it re-creates a slice of the defect between every pair of columns. Each
+ * number below is roughly the cell's own width at the CURRENT container, which is small;
+ * at narrow widths min-content wins anyway (see the next paragraph), so the visible effect
+ * on a 1440 monitor is nil.
+ *
+ * ⚠️ A PERCENTAGE IS A PREFERENCE, NOT A FLOOR-AND-CEILING. Under automatic table layout
+ * a column is never squeezed below its MIN-CONTENT width, so on a narrow viewport a
+ * column whose percentage is smaller than its content simply takes what it needs and the
+ * primary gives way. That is the correct behaviour — these ledgers exist to place the
+ * SURPLUS on a wide screen, not to compress anything on a narrow one — but it does mean
+ * the percentages are only observably in force once the table is wider than the sum of
+ * its columns' min-content widths.
+ *
+ * 🔴 NO WIDTH-CONDITIONAL COLUMN SET. A ledger is a list of WIDTHS for a fixed set of
+ * columns; a column that exists only on wide screens would be a discoverability and an
+ * a11y problem (screen readers and narrow viewports would see a different table) and it
+ * would double the test surface. Where a table legitimately has two SHAPES — the review
+ * queue's Deploy column, the revenue table's App column — both shapes are enumerated
+ * here as their own ledger and the choice is made by DATA, never by width.
+ */
+
+/**
+ * One table's column widths, in document order.
+ *
+ * A `number` is a percentage of the table's width. `null` marks the PRIMARY column,
+ * which is deliberately given no width at all so it absorbs whatever the percentages
+ * leave — the whole point of the ledger.
+ */
+export type AppsTableColumns = readonly (number | null)[];
+
+/**
+ * Everything wrong with a ledger, as messages. Empty means valid.
+ *
+ * 🔴 A FUNCTION RATHER THAN AN INLINE ASSERTION, so the RULE has a test of its own on
+ * inputs that must be rejected. A validator whose only inputs are the four real ledgers
+ * — all of which pass — is a validator nobody has watched work.
+ */
+export function appsTableColumnProblems(label: string, columns: AppsTableColumns): string[] {
+ const problems: string[] = [];
+ if (columns.length === 0) {
+ return [`${label}: an empty ledger describes no table`];
+ }
+ const primaries = columns.filter((c) => c === null).length;
+ if (primaries !== 1) {
+ problems.push(
+ `${label}: exactly ONE column must be the primary (null) so it can absorb the ` +
+ `slack — found ${primaries}`
+ );
+ }
+ for (const [i, c] of columns.entries()) {
+ if (c === null) continue;
+ if (!Number.isFinite(c) || c <= 0) {
+ problems.push(`${label}: column ${i} must be a positive percentage, got ${c}`);
+ }
+ }
+ const total = columns.reduce((sum, c) => sum + (c ?? 0), 0);
+ // Strictly less than 100: at exactly 100 the primary column would be handed nothing,
+ // which is a ledger that has stopped doing the one thing it exists for.
+ if (total >= 100) {
+ problems.push(
+ `${label}: the non-primary columns claim ${total}% — they must leave the primary ` +
+ `column a share of its own (< 100%)`
+ );
+ }
+ return problems;
+}
+
+/**
+ * The `/apps/review` QUEUE table (`UnifiedReviewList`) — Kind · **App** · Submitter ·
+ * date · [Deploy] · action.
+ *
+ * The App column is primary: it is the only cell carrying a variable-length identity
+ * (a slug plus an optional title), and it is what a moderator scans down. Everything
+ * else is a badge, a username, a formatted date or a button — all of which have a
+ * natural width that more space does not improve.
+ *
+ * The Deploy column exists on the Approved tab only, so BOTH shapes are enumerated
+ * rather than one being patched at the call site. Its presence is decided by whether a
+ * retrigger handler was supplied — i.e. by data, never by width.
+ */
+export const APPS_REVIEW_QUEUE_COLUMNS = {
+ /** Pending / Rejected: Kind · App · Submitter · date · action. */
+ withoutDeploy: [6, null, 6, 9, 6] as AppsTableColumns,
+ /** Approved: Kind · App · Submitter · date · Deploy · action. */
+ withDeploy: [5, null, 5, 8, 8, 5] as AppsTableColumns,
+} as const;
+
+/**
+ * The `/apps/mine` table (`MyAppsBody`) — **App** · Cover · Status · Updated.
+ *
+ * App is primary for the same reason as above, and here it also carries the icon and
+ * the slug, so it is the cell that most wants the room. Cover is a fixed 96px image, so
+ * its 12% is a floor rather than an aspiration; Status holds up to three badges plus
+ * the completeness advisory, which is why it is the widest fixed share.
+ */
+export const APPS_MINE_COLUMNS: AppsTableColumns = [null, 5, 10, 5];
+
+/**
+ * The `/apps/review` MANAGE-LISTINGS table (`AppListingsModerationTable`) —
+ * **App** · Owner · Category · Reviews · actions.
+ *
+ * The action cell is a `Group` of buttons plus a menu, so it gets the second-largest
+ * share; it still cannot be primary, because its natural width is set by its buttons
+ * and handing it the slack would push the buttons away from the row again.
+ */
+export const APPS_MOD_LISTINGS_COLUMNS: AppsTableColumns = [null, 5, 5, 4, 13];
+
+/**
+ * The revenue attributions table (`RevenuePanel`) — Date · [App] · **Scope** · Buzz ·
+ * Gross · Your share · Status.
+ *
+ * TWO SHAPES, and the primary column MOVES between them, which is why they are two
+ * ledgers rather than one with a hole:
+ * · unscoped (`/apps/revenue`) — App is a link to a per-app page and is primary;
+ * · scoped (`/apps//revenue`) — there IS no App column, because every row is the
+ * same app, so Scope takes the slack.
+ * Both are numbers-only decisions about a fixed column set; nothing here is width-aware.
+ */
+export const APPS_REVENUE_COLUMNS = {
+ /** `/apps/revenue`: Date · App · Scope · Buzz · Gross · Your share · Status. */
+ withApp: [5, null, 7, 5, 5, 6, 7] as AppsTableColumns,
+ /** `/apps//revenue`: Date · Scope · Buzz · Gross · Your share · Status. */
+ scoped: [6, null, 6, 6, 7, 8] as AppsTableColumns,
+} as const;
+
+/**
+ * The `/apps/review` ACTIVE-PREVIEWS panel (`ActivePreviewsPanel`) —
+ * **App** · Version · State · Age · actions.
+ *
+ * 🔴 THIS LEDGER IS THE ONE THAT PROVES THE POINT OF ENUMERATING THEM. `/apps/review`
+ * renders FOUR tables, and the first pass gave ledgers to two of them while removing the
+ * 1368 cap that had been holding the other two down. Measured on the real panel in the
+ * real layout, 1440 → 2560, with no ledger:
+ *
+ * columns 228.02 | 165.17 | 146.45 | 152.05 | 682.31 (1440)
+ * 413.89 | 299.83 | 265.84 | 276.00 | 1238.44 (2560)
+ * slug → "Tear down" gap 817.36 → 1381.23 (+563.87)
+ *
+ * ⚠️ THAT PAIR WAS FIRST RECORDED AS `609.67 → 1173.55`, WHICH IS A DIFFERENT QUANTITY.
+ * The two differ by which endpoint the gap is measured to: 609.67/1173.55 is the ``
+ * BORDER BOX to the row's FIRST control (the "Open full-page preview" anchor); the shipped
+ * helper measures the slug's GLYPH RANGE to the "Tear down" BUTTON. The DELTA is +563.87
+ * either way, so nothing about the argument moved — but a live assertion message that
+ * quoted one pair while printing the other reads as a broken harness, so the numbers here
+ * are the ones the guard actually produces.
+ *
+ * i.e. half the container's 1120px delta landed between a row's identity and the control
+ * acting on it — the exact phenomenon the header of this file calls THE DEFECT, on the
+ * route whose cap this change removes. Uncapping a page is a claim about EVERY table on
+ * it, which is why `__tests__/appsWideLayout.test.ts` now enumerates them instead of
+ * naming the ones somebody remembered.
+ *
+ * 🔴 THE PRIMARY IS THE **ACTION** COLUMN — case (b) at the top of this file, and this
+ * table is the worked example. Every data cell here is short and fixed in kind: a slug, a
+ * version, a state badge, a relative age. Making `App` primary on the general rule put the
+ * slack *inside* the column the reader has to scan across to reach the button, which is
+ * the defect with extra steps. With the action column primary, the surplus lands past the
+ * buttons.
+ *
+ * 🔴 AND THE FOUR SHARES ARE DELIBERATELY TOO SMALL TO BIND — this is the shrink-to-content
+ * idiom, not a proportion. A percentage is a preference floored at min-content, so a share
+ * smaller than the cell needs resolves to the cell's own width at EVERY container width,
+ * which is what makes those four columns CONSTANT. It has to be spelled this way here:
+ * ordinary content-sized shares (9/7/6/6) still grow with the table, and measured on this
+ * fixture that alone moved the slug → "Tear down" gap 510.38 → 823.95. The same numbers
+ * with these shares hold it flat.
+ */
+export const APPS_ACTIVE_PREVIEWS_COLUMNS: AppsTableColumns = [3, 2, 2, 2, null];
+
+/**
+ * 🔴 THERE IS NO `APPS_OFFSITE_REPORTS_COLUMNS` EITHER, for the same measured reason as the
+ * activity table above — and this one took three wrong ledgers to establish.
+ *
+ * Round 2 made `App` primary, round 3 made `Reason` primary (it is
+ * `lineClamp={2} maxWidth: 260`, so it absorbed +816.91 for nothing), and round 4 made the
+ * actions column primary under case (b). The last one is right in KIND and still
+ * unshippable, because at 1200 this row's content wants
+ *
+ * App 240 + Reason 292 + Reporter 94 + Reported 133 + Status 86 + actions 414 = 1259px
+ *
+ * in 1168px of container. Something is under-served at 1200 whatever the split, and the
+ * browser's own layout picks the least-bad one. Measured: every candidate was either
+ * TALLER than natural at 1200 (105.48 / 177.88 against 88.69) or clipped the lineClamp-ed
+ * 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 as height.
+ *
+ * 🔴 THE `justify="flex-end"` ON ITS ACTION GROUP IS THEREFORE BACK. That flip was correct
+ * ONLY as part of case (b): with no ledger the column sits at its min-content and the two
+ * alignments render identically, so the flip would have been an unjustified change.
+ */
+
+/**
+ * 🔴 THERE IS NO `APPS_ACTIVITY_COLUMNS`, AND THAT IS A MEASURED DECISION.
+ *
+ * `/apps/installed`'s activity tab (`AppActivityPanel`) carried one for two rounds and it
+ * was wrong both times — first with `Detail` primary (a fixed monospace ref), then with
+ * shares set from a 1408 measurement, which squeezed three columns below their content at
+ * every width a real desktop uses. Measured on a rich `tip` row, ROW HEIGHT:
+ *
+ * 768 1200 1440 2560
+ * no ledger (natural) 36.19 36.19 36.19 36.19
+ * [7, 8, 10, null, 6] (round 2) 48.09 48.09 48.09 36.19
+ * [3, 4, 20, 13, null] (round 3) 64.89 64.89 64.89 48.09
+ *
+ * Round 3 was 79% taller than natural at 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 on both.
+ *
+ * 🔴 AND NO LEDGER FIXES IT, WHICH IS THE POINT. 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 all still wrapped at 768 (48.09). The one configuration
+ * that holds a single line everywhere — `[16, 12, 27, 25, null]` — reproduces natural
+ * layout at the wide end to within ~15px on three of five columns:
+ *
+ * ledger @2560 404.47 | 303.36 | 682.55 | 632.00 | 505.63
+ * natural @2560 411.09 | 609.14 | 667.58 | 615.19 | 225.00
+ *
+ * So the choice is between a ledger that wraps text on a laptop and a ledger that is
+ * natural layout with extra steps. Both are worse than none, and this module's own rule —
+ * a share larger than its cell needs is padding relabelled — rules out the second. The
+ * table is EXEMPT; the guard in `__tests__/appsWideLayout.test.ts` records that as a
+ * `no-surplus` exemption and requires a geometry arm to keep proving it.
+ */
+
+/**
+ * The agent code-review report's scope table (`ReportTabs`) —
+ * Scope · Used · Justified · Sensitive · Evidence · **Notes**.
+ *
+ * 🔴 NOTES IS PRIMARY. `Scope` is a fixed-shape identifier (`models:read:self`); `Used`,
+ * `Justified` and `Sensitive` are booleans. The two free-text columns are `Evidence` and
+ * `Notes`, and only one can be primary — `Evidence` therefore takes the largest FIXED
+ * share and `Notes`, which is the reviewer's own prose and the least bounded of the two,
+ * takes the slack.
+ *
+ * Reachable on `/apps/review/[publishRequestId]` (via `OnsiteReviewModalBody` →
+ * `AgentReviewPanel`), which takes the full container — so it is in scope even though it
+ * is flag-gated (`appBlocksAgenticReview`) and also renders inside a modal elsewhere. A
+ * ledger is inert in the modal (the table is narrower than the sum of its min-contents
+ * there) and load-bearing on the page, which is the right way round.
+ */
+export const APPS_AGENT_REPORT_SCOPE_COLUMNS: AppsTableColumns = [7, 5, 6, 6, 22, null];
+
+/** Every ledger in this module, so a guard can sweep them all rather than a sample. */
+export const APPS_TABLE_COLUMN_LEDGERS: Readonly> = {
+ 'review queue (pending/rejected)': APPS_REVIEW_QUEUE_COLUMNS.withoutDeploy,
+ 'review queue (approved)': APPS_REVIEW_QUEUE_COLUMNS.withDeploy,
+ 'my apps': APPS_MINE_COLUMNS,
+ 'moderation listings': APPS_MOD_LISTINGS_COLUMNS,
+ 'revenue (unscoped)': APPS_REVENUE_COLUMNS.withApp,
+ 'revenue (scoped)': APPS_REVENUE_COLUMNS.scoped,
+ 'active previews': APPS_ACTIVE_PREVIEWS_COLUMNS,
+ 'agent report scopes': APPS_AGENT_REPORT_SCOPE_COLUMNS,
+};
+
+/**
+ * The proportional `` for a `/apps/*` table.
+ *
+ * 🔴 IT MUST BE THE TABLE'S FIRST CHILD, before `` — HTML puts ``
+ * ahead of any row group, and only a document that says so is valid.
+ *
+ * ⚠️ AND THE OBVIOUS JUSTIFICATION FOR THAT RULE IS WRONG HERE, SO DO NOT REPEAT IT.
+ * "A browser ignores a misplaced ` `" is what this comment used to say, and it
+ * was refuted by mutating it: measured 2026-09-04 in `AppsWideLayout.geometry.test.tsx`,
+ * moving this element AFTER `` changed no rendered width at all — every
+ * geometry assertion in that file still passed. React inserts nodes through the DOM API rather than
+ * the HTML parser, so the parser's table foster-parenting never runs and Chromium applies
+ * the columns from wherever the element sits. The ordering is therefore a VALIDITY rule
+ * enforced structurally in `__tests__/appsWideLayout.test.ts`, not something the pixels
+ * can see — and a guard whose stated reason is false is the kind that gets deleted.
+ *
+ * 🔴 THE PRIMARY COLUMN GETS NO `width` AT ALL, and that absence is the mechanism.
+ * Under CSS automatic table layout a column with a specified width is given it; what is
+ * left over goes to the columns WITHOUT one. With exactly one such column, "what is left
+ * over" is the whole surplus, which is precisely "the primary column absorbs the slack".
+ * Giving it a percentage instead would make it merely another proportional column and
+ * the extra width would go back to being distributed as padding.
+ */
+export function AppsTableColgroup({ columns }: { columns: AppsTableColumns }) {
+ return (
+
+ {columns.map((pct, i) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * How many tracks {@link AppsCardGrid} resolves to at a given CONTENT width — the
+ * `repeat(auto-fill, minmax(min, 1fr))` arithmetic, as a pure function so the ladder
+ * can be pinned without a browser.
+ *
+ * Mirrors the CSS: `auto-fill` fits `floor((available + gap) / (min + gap))` tracks, and
+ * never fewer than one.
+ */
+export function appsCardGridColumnsAt(
+ contentWidth: number,
+ minColumn: number = APPS_CARD_LIST_MIN_COLUMN,
+ gap: number = APPS_CARD_LIST_GAP
+): number {
+ return Math.max(1, Math.floor((contentWidth + gap) / (minColumn + gap)));
+}
+
+/** The content width a route with no measure gets from the shared container. */
+export const APPS_FULL_MEASURE_CONTENT_WIDTH = APPS_PAGE_CONTAINER_WIDTH - APPS_CONTAINER_GUTTER;
+
+/** The content width the SAME route got from the container before the ultrawide pass. */
+export const APPS_LEGACY_CONTENT_WIDTH = APPS_LEGACY_CONTAINER_WIDTH - APPS_CONTAINER_GUTTER;
+
+/**
+ * A card list that spends surplus width on COLUMNS rather than on stretching one card.
+ *
+ * Replaces a `` of full-width cards.
+ *
+ * 🔴 THE INNER `min(100%, N)` IS LOAD-BEARING, AND ITS FAILURE MODE IS WORSE THAN THIS
+ * COMMENT USED TO SAY. It claimed a bare `minmax(1200px, 1fr)` "overflows horizontally".
+ * Measured at 390×844 with the `min()` removed: `gridBox=358`, `gridScroll=1200`,
+ * `child=1200`, and `document.scrollWidth` **unchanged** — so the card is 1200px wide
+ * inside a 358px grid, CLIPPED, with no scrollbar and no page-level overflow to notice
+ * it by. Nothing on screen says the content is cut off. That matters because this
+ * component converted three phone-reachable `Stack`s on `/apps/installed` into grids, so
+ * the narrow case is a real users' case rather than a theoretical one, and it is pinned
+ * at a phone viewport in `AppsWideLayout.geometry.test.tsx`.
+ *
+ * `alignItems: start` is deliberate and is NOT the store grid's bug: these cards are
+ * independent blocks with nothing bottom-pinned inside them (compare
+ * `AppListingsMarketplaceBody.stretch.geometry.test.tsx`, where stretching is what makes
+ * `h-full` resolve), so stretching them to the tallest row member would only inflate the
+ * short ones.
+ */
+export function AppsCardGrid({
+ children,
+ testId,
+ gap = APPS_CARD_LIST_GAP,
+}: {
+ children: ReactNode;
+ /** Optional `data-testid`, so a page's existing list id survives the swap. */
+ testId?: string;
+ /**
+ * Track gap in px. Defaults to {@link APPS_CARD_LIST_GAP} (Mantine `md`).
+ *
+ * 🔴 IT IS A PROP BECAUSE THE PROVENANCE RULE APPLIES TO SPACING TOO. The lists this
+ * replaced did not all use the same gap — `/apps/installed`'s Hidden tab was
+ * `` (12px) — and defaulting every one of them to 16 would have moved
+ * something a 1440 monitor shows, which is precisely what
+ * `APPS_CARD_LIST_MIN_COLUMN`'s "nothing a 1440 or 1920 monitor shows changes" claim
+ * forbids. Both gaps yield the SAME column ladder at both container widths (pinned in
+ * `__tests__/appsWideLayout.test.ts`), so carrying the original number costs nothing.
+ */
+ gap?: number;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/pages/apps/installed.tsx b/src/pages/apps/installed.tsx
index ff8cfde8c9..0b75c79e30 100644
--- a/src/pages/apps/installed.tsx
+++ b/src/pages/apps/installed.tsx
@@ -30,6 +30,7 @@ import { NotFound } from '~/components/AppLayout/NotFound';
import { openAppSettingsModal } from '~/components/Apps/AppSettingsModal';
import { Meta } from '~/components/Meta/Meta';
import { AppsPageLayout } from '~/components/Apps/AppsPageLayout';
+import { AppsCardGrid } from '~/components/Apps/appsWideLayout';
import { groupSubscriptionsByApp } from '~/components/Apps/groupSubscriptionsByApp';
import type { GroupedApp } from '~/components/Apps/groupSubscriptionsByApp';
import { useHiddenBlockList, unhideBlock } from '~/components/AppBlocks/hiddenBlocks';
@@ -217,7 +218,9 @@ interface InstalledAppCardProps {
* surfaces are active happens through the existing AppSettingsModal (the
* Manage button), which already supports both scopes.
*/
-function InstalledAppCard({ app, onManage }: InstalledAppCardProps) {
+/** 🔴 EXPORTED SO ITS GEOMETRY CAN BE MEASURED — `AppsWideLayout.geometry.test.tsx`
+ * mounts THIS card rather than a fixture copy of its markup. */
+export function InstalledAppCard({ app, onManage }: InstalledAppCardProps) {
const { blanketPublisher, blanketViewer, pinned } = app;
const name = app.manifest.name ?? app.blockId;
// Any blanket sub on the app is enough to seed the Manage modal — it
@@ -355,7 +358,7 @@ function ScopeGrantsPanel() {
return ;
}
return (
-
+
{grants.map((grant) => (
@@ -379,7 +382,7 @@ function ScopeGrantsPanel() {
))}
-
+
);
}
@@ -409,7 +412,10 @@ function HiddenBlocksPanel() {
}
return (
-
+ /* `gap={12}` — this list was ``, not `md`. Carrying its own number
+ keeps `APPS_CARD_LIST_MIN_COLUMN`'s "nothing a 1440 or 1920 monitor shows changes"
+ literally true on this tab; the column ladder is identical at both gaps. */
+
{hidden.map((block) => (
@@ -446,7 +452,7 @@ function HiddenBlocksPanel() {
))}
-
+
);
}
@@ -530,11 +536,13 @@ export default function InstalledAppsPage() {
) : groupedApps.length === 0 ? (
) : (
-
+ /* A GRID, NOT A `Stack` — the 640px dead-gap fix. Rationale + the measured
+ ladder: `APPS_CARD_LIST_MIN_COLUMN` in `~/components/Apps/appsPageWidths`. */
+
{groupedApps.map((app) => (
))}
-
+
)}
diff --git a/src/pages/apps/review.tsx b/src/pages/apps/review.tsx
index 2c068d6ba2..ec2fcee26b 100644
--- a/src/pages/apps/review.tsx
+++ b/src/pages/apps/review.tsx
@@ -33,7 +33,6 @@ import type {
} from '~/components/Apps/unifiedReviewRow';
import { Meta } from '~/components/Meta/Meta';
import { AppsPageLayout } from '~/components/Apps/AppsPageLayout';
-import { APPS_PAGE_MEASURES } from '~/components/Apps/appsPageWidths';
import { EMBEDDED_KIND_LABEL, STANDALONE_KIND_LABEL } from '~/components/Apps/listingKindLabels';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { isAppReviewer } from '~/shared/utils/app-blocks-access';
@@ -183,8 +182,15 @@ export default function ReviewQueuePage() {
return (
<>
+ {/*
+ 🔴 NO `measure` — this page took a 1368 cap until the wide-tables pass, and
+ removing it is the point rather than a side effect. The cap existed because four
+ short columns could not spend the container, so the surplus landed as padding
+ between the last column and the Review button. `UnifiedReviewList` now carries a
+ proportional `` (`APPS_REVIEW_QUEUE_COLUMNS`), so the width goes into
+ the App column instead. Re-adding a cap here would hide that rather than help it.
+ */}
diff --git a/test/geometry-setup.tsx b/test/geometry-setup.tsx
index 51043c01b1..ad00fdbb0c 100644
--- a/test/geometry-setup.tsx
+++ b/test/geometry-setup.tsx
@@ -96,25 +96,27 @@
* the vacuous pass this harness exists to remove.
*
* ─────────────────────────────────────────────────────────────────────────────
- * 🔴 WHAT RUNS THIS, TODAY: NOTHING. SAY IT OUT LOUD.
+ * 🔴 WHAT RUNS THIS: THE `Geometry tests` JOB — AND IT IS REPORT-ONLY ON A PR.
* ─────────────────────────────────────────────────────────────────────────────
- * `.github/workflows/lint.yml` selects projects by name — `--project 'unit*'`,
- * `--project '@civitai/*'`, `--project 'app:*'`. None of those patterns matches
- * `component` and none matches `geometry`, because the Actions runners install no
- * Chromium (the `unit` job's own comment says so). The `component` tier's only CI
- * home is the preview pipeline's `preview / component-tests` status, which is
- * report-only. This project has no CI home at all yet.
+ * ⚠️ 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.
*
- * That is a deliberate scope boundary, not an oversight: wiring a new job is a
- * change to a pipeline, not to a test harness, and it deserves its own review. But
- * it has to be stated, because a harness nothing runs rots — the assertions here
- * would drift from the components they measure and nobody would learn of it until
- * someone ran `pnpm test:geometry` by hand. Until this project is wired into a
- * job, treat it as a tool you run deliberately, NOT as a gate you are behind.
+ * 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.
*
- * Two things are already true that make wiring it cheap when someone does:
- * `pnpm run test:geometry` is the whole command, and the glob is disjoint from
- * every other project's, so adding it cannot change what any existing job runs.
+ * `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 ─────────────────────────────────────────