mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge pull request #700 from MBombeck/fix/header-seam-banners
Keep the header seam continuous when a banner is showing
This commit is contained in:
6 files changed
+550
-87
No files matched your search
@@ -0,0 +1,422 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* The seam again, this time with chrome above it.
|
||||
*
|
||||
* `chrome-header-seam.spec.ts` measures the two header borders on a shell with
|
||||
* nothing above them. That is the state almost every session is in, and it is
|
||||
* also the only state in which the seam cannot break: with no banner painted,
|
||||
* the sidebar logo band and the top bar both start at y=0 whatever the layout
|
||||
* does with them.
|
||||
*
|
||||
* The banners are the case that breaks it. Each one is a full-width strip that
|
||||
* paints above the top bar, and while they lived INSIDE the content column they
|
||||
* pushed the top bar down while the sidebar band stayed where it was — one line
|
||||
* across the top of the app became two lines a banner-height apart, and three
|
||||
* can stack at once. Measured before the fix, on the French locale strip: one
|
||||
* banner offset the two borders by 66.5px at 768px and 61.0px at 1280px, and
|
||||
* all three together by 184.5px and 143.0px. So this spec forces them on and
|
||||
* measures the same two borders.
|
||||
*
|
||||
* ## How each banner is forced
|
||||
*
|
||||
* Honestly, through the condition the component actually reads — no injected
|
||||
* DOM, no test-only prop:
|
||||
*
|
||||
* - **Maintainership** paints on a locale HealthLog does not hand-maintain.
|
||||
* The context cookie is switched from `en` to `fr`, which is what a
|
||||
* French-speaking user's browser carries. The swap happens with no page
|
||||
* alive (`about:blank`): `I18nProvider` rewrites `healthlog-locale` from
|
||||
* its own active locale on mount, so a cookie written under a live English
|
||||
* page is reverted within the second.
|
||||
* - **Shared record** paints from `accountAccess.active` on `/api/auth/me`.
|
||||
* The real payload is captured first and served back with that block
|
||||
* filled, rather than performing a real switch: the switch is stamped on
|
||||
* the SESSION ROW, so switching the shared cookie jar would switch it for
|
||||
* every other spec holding the same cookie (see
|
||||
* `DELEGATE_STORAGE_STATE_PATH` in `global-setup.ts` for what that costs).
|
||||
* A captured-and-patched payload keeps this file's reach inside this file,
|
||||
* and it keeps working after the offline stage because it never touches the
|
||||
* network again.
|
||||
* - **Offline** paints while `navigator.onLine` is false, which
|
||||
* `context.setOffline(true)` produces for real, event and all. It is armed
|
||||
* last because it is the only stage a navigation cannot follow.
|
||||
*
|
||||
* The demo banner is the one that cannot be forced from a spec: `DEMO_MODE` is
|
||||
* a server env var read by the root layout, and the suite shares one server
|
||||
* whose mutations would all be blocked by turning it on. It is the same strip
|
||||
* as the other three (`border-b px-3 py-2 text-xs`) in the same place in the
|
||||
* stack, so the three forced here exercise its geometry; nothing in the stack
|
||||
* is specific to which banner is in it.
|
||||
*
|
||||
* ## What is asserted
|
||||
*
|
||||
* Two properties, and the second is why the banners were hoisted above the
|
||||
* shell row rather than nudged inside it:
|
||||
*
|
||||
* 1. The seam holds — the two header borders keep sharing one line at every
|
||||
* stack depth, so chrome above the header cannot displace one band and
|
||||
* not the other.
|
||||
* 2. The shell stays inside the viewport — the banners are part of the
|
||||
* `h-dvh` budget, not added on top of it, so no depth of stack introduces
|
||||
* a page-level scrollbar. `<main>` remains the only vertical scroller.
|
||||
*
|
||||
* Property 2 runs on mobile too, where there is no sidebar and therefore no
|
||||
* seam, because the scroll model is the half of the layout that the hoist could
|
||||
* have broken at that breakpoint.
|
||||
*/
|
||||
|
||||
const LOCALE_COOKIE = "healthlog-locale";
|
||||
|
||||
/**
|
||||
* Every banner that can paint above the top bar, so a stage can assert how many
|
||||
* are up rather than only that the one it armed is.
|
||||
*/
|
||||
const BANNER_SLOTS = [
|
||||
"offline-banner",
|
||||
"shared-record-banner",
|
||||
"demo-banner",
|
||||
"maintainership-banner",
|
||||
] as const;
|
||||
|
||||
type Band = { bottom: number; height: number; borderBottomWidth: number };
|
||||
|
||||
type ShellGeometry = {
|
||||
topBar: Band | null;
|
||||
sidebar: Band | null;
|
||||
bannerCount: number;
|
||||
bannerHeight: number;
|
||||
/** `scrollHeight - clientHeight` of the document's scrolling element. */
|
||||
documentOverflow: number;
|
||||
viewportHeight: number;
|
||||
};
|
||||
|
||||
async function readShell(page: Page): Promise<ShellGeometry> {
|
||||
return page.evaluate(
|
||||
(slots: string[]) => {
|
||||
const band = (slot: string) => {
|
||||
const el = document.querySelector(`[data-slot="${slot}"]`);
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
bottom: rect.bottom,
|
||||
height: rect.height,
|
||||
borderBottomWidth: parseFloat(
|
||||
getComputedStyle(el).borderBottomWidth || "0",
|
||||
),
|
||||
};
|
||||
};
|
||||
const banners = slots
|
||||
.map((slot) => document.querySelector(`[data-slot="${slot}"]`))
|
||||
.filter((el): el is Element => el != null);
|
||||
const scroller = document.scrollingElement ?? document.documentElement;
|
||||
return {
|
||||
topBar: band("top-bar"),
|
||||
sidebar: band("sidebar-header"),
|
||||
bannerCount: banners.length,
|
||||
bannerHeight: banners.reduce(
|
||||
(sum, el) => sum + el.getBoundingClientRect().height,
|
||||
0,
|
||||
),
|
||||
documentOverflow: scroller.scrollHeight - scroller.clientHeight,
|
||||
viewportHeight: window.innerHeight,
|
||||
};
|
||||
},
|
||||
BANNER_SLOTS as unknown as string[],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live `/api/auth/me` body, read through the page so it carries the page's
|
||||
* own cookies. Captured before anything else is armed, so the mock that later
|
||||
* replaces it differs from the real payload in exactly one block.
|
||||
*/
|
||||
async function captureAccountPayload(page: Page): Promise<string> {
|
||||
const raw = await page.evaluate(async () => {
|
||||
const res = await fetch("/api/auth/me", { credentials: "same-origin" });
|
||||
return res.ok ? await res.text() : null;
|
||||
});
|
||||
expect(
|
||||
raw,
|
||||
"could not capture /api/auth/me for the sharing mock",
|
||||
).not.toBeNull();
|
||||
return raw!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the captured payload back with the sharing block filled in, so the
|
||||
* shell believes this session is inside somebody else's record. Everything else
|
||||
* the shell reads (modules, role, onboarding flags) stays what the server said.
|
||||
*/
|
||||
async function armSharedRecord(page: Page, captured: string): Promise<void> {
|
||||
const payload = JSON.parse(captured) as { data: Record<string, unknown> };
|
||||
const entry = {
|
||||
accountId: "seam-fixture-account",
|
||||
username: "seam-owner",
|
||||
displayName: null,
|
||||
access: "read" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
payload.data.accountAccess = {
|
||||
accounts: [entry],
|
||||
active: entry,
|
||||
canSwitch: true,
|
||||
};
|
||||
const body = JSON.stringify(payload);
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the context's pinned locale for one HealthLog ships as an AI-initial
|
||||
* translation, so the maintainership strip has a reason to paint.
|
||||
*
|
||||
* The page is parked on `about:blank` for the swap. `I18nProvider` mirrors its
|
||||
* ACTIVE locale into this cookie from an effect, so a cookie written while an
|
||||
* English page is mounted is reverted before the next navigation can read it —
|
||||
* measured, not assumed: the value flipped back inside one second. With no page
|
||||
* running, the next navigation sends `fr` and the provider then mirrors `fr`.
|
||||
*/
|
||||
async function armUnmaintainedLocale(page: Page): Promise<void> {
|
||||
const host = new URL(page.url()).hostname;
|
||||
await page.goto("about:blank");
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: LOCALE_COOKIE,
|
||||
value: "fr",
|
||||
domain: host,
|
||||
path: "/",
|
||||
expires: Math.floor(Date.now() / 1000) + 3600,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the context offline and make sure the banner hears about it.
|
||||
*
|
||||
* `setOffline` produces the real condition — `navigator.onLine` flips to false,
|
||||
* which is asserted here rather than assumed. Delivery of the `offline` EVENT
|
||||
* to an already-loaded page is the part that is not reliable: this spec caught
|
||||
* it missing once under a fully parallel run while passing three for three on
|
||||
* its own. `<OfflineBanner>` only reads `navigator.onLine` at mount and
|
||||
* otherwise waits on the event, so the event is re-dispatched here. That is the
|
||||
* notification, not the condition, and the condition is verified first.
|
||||
*/
|
||||
async function armOffline(page: Page): Promise<void> {
|
||||
// Let whatever is in flight land first. The Playwright context runs with
|
||||
// `serviceWorkers: "block"`, so nothing has pre-cached the app shell and a
|
||||
// lazily-imported chunk cut off mid-download throws the whole tree to the
|
||||
// global error boundary — "Failed to load chunk … from module …", no top bar,
|
||||
// no banner. That is a property of the harness rather than of the shell (a
|
||||
// real install serves those chunks from the worker cache), so it is waited
|
||||
// out rather than asserted on. Best-effort: a polling surface can keep the
|
||||
// network from ever going fully idle.
|
||||
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
|
||||
await page.context().setOffline(true);
|
||||
await expect.poll(() => page.evaluate(() => navigator.onLine)).toBe(false);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("offline")));
|
||||
}
|
||||
|
||||
test.describe("app-chrome header seam under banners", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("the header seam survives every depth of the banner stack", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(
|
||||
test.info().project.name === "chromium-mobile",
|
||||
"the sidebar is desktop-only (md+), so below md there is no seam to measure",
|
||||
);
|
||||
// Four stages measured at two widths, with a full page load per stage and
|
||||
// a network settle either side of the offline one. Comfortably inside the
|
||||
// suite default when it runs alone; the default 30s is not enough headroom
|
||||
// for it on a loaded machine.
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const topBar = page.locator('[data-slot="top-bar"]');
|
||||
const sidebarHeader = page.locator('[data-slot="sidebar-header"]');
|
||||
await expect(topBar).toBeVisible();
|
||||
await expect(sidebarHeader).toBeVisible();
|
||||
|
||||
const captured = await captureAccountPayload(page);
|
||||
|
||||
// Each stage adds one banner and leaves the previous ones up, so the last
|
||||
// stage measures the whole stack rather than one banner at a time.
|
||||
//
|
||||
// `beforeResize` / `afterResize` exist for the offline stage alone: a
|
||||
// viewport change can pull a chunk the page has not loaded yet, and a chunk
|
||||
// fetch that fails with no network takes the whole shell down to the global
|
||||
// error boundary. Observed once under `--repeat-each=3`, as a missing top
|
||||
// bar and a "Failed to load chunk" screen. So the network comes back for
|
||||
// the resize and the stage re-arms after it — the measurement is still
|
||||
// taken offline, with the banner up.
|
||||
type Stage = {
|
||||
label: string;
|
||||
arm: () => Promise<void>;
|
||||
beforeResize?: () => Promise<void>;
|
||||
afterResize?: () => Promise<void>;
|
||||
};
|
||||
const stages: Stage[] = [
|
||||
{ label: "no banner", arm: async () => {} },
|
||||
{
|
||||
label: "1 banner (maintainership)",
|
||||
arm: async () => {
|
||||
await armUnmaintainedLocale(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-slot="maintainership-banner"]'),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "2 banners (+ shared record)",
|
||||
arm: async () => {
|
||||
await armSharedRecord(page, captured);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "3 banners (+ offline)",
|
||||
arm: async () => {},
|
||||
beforeResize: async () => {
|
||||
await page.context().setOffline(false);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.onLine))
|
||||
.toBe(true);
|
||||
},
|
||||
afterResize: async () => {
|
||||
await armOffline(page);
|
||||
await expect(
|
||||
page.locator('[data-slot="offline-banner"]'),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Stage outside, width inside: each banner is armed once and the resulting
|
||||
// stack measured at both widths. 768px is the `md` breakpoint where the
|
||||
// sidebar first appears — the narrowest shell that has a seam at all, and
|
||||
// the width where a banner is most likely to wrap onto a second line.
|
||||
for (const [index, stage] of stages.entries()) {
|
||||
await stage.arm();
|
||||
|
||||
for (const width of [768, 1280] as const) {
|
||||
await stage.beforeResize?.();
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.innerWidth))
|
||||
.toBe(width);
|
||||
await stage.afterResize?.();
|
||||
await expect(topBar).toBeVisible();
|
||||
await expect(sidebarHeader).toBeVisible();
|
||||
|
||||
const shell = await readShell(page);
|
||||
const at = `at ${width}px, ${stage.label}`;
|
||||
|
||||
expect(shell.topBar, `top bar missing ${at}`).not.toBeNull();
|
||||
expect(shell.sidebar, `sidebar band missing ${at}`).not.toBeNull();
|
||||
const top = shell.topBar!;
|
||||
const side = shell.sidebar!;
|
||||
|
||||
// Two borders that do not exist also "line up".
|
||||
expect(
|
||||
top.borderBottomWidth,
|
||||
`top bar has no bottom border ${at}`,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
side.borderBottomWidth,
|
||||
`sidebar band has no bottom border ${at}`,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// The stage armed what it said it armed. Without this a change that
|
||||
// stopped every banner from painting would make the seam assertion
|
||||
// below trivially true.
|
||||
expect(
|
||||
shell.bannerCount,
|
||||
`${at}: expected ${index} banner(s) painted, found ${shell.bannerCount}`,
|
||||
).toBe(index);
|
||||
if (index > 0) {
|
||||
expect(
|
||||
shell.bannerHeight,
|
||||
`${at}: the banners paint no height, so this stage proves nothing`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// The seam. A banner that displaces one band and not the other shows
|
||||
// up here as a step the size of the stack.
|
||||
expect(
|
||||
Math.abs(side.bottom - top.bottom),
|
||||
`${at}: header seam steps by ${(side.bottom - top.bottom).toFixed(
|
||||
2,
|
||||
)}px — the banner stack (${shell.bannerHeight.toFixed(
|
||||
2,
|
||||
)}px) is displacing one band and not the other`,
|
||||
).toBeLessThan(1);
|
||||
|
||||
// The banners come out of the viewport budget, not on top of it.
|
||||
expect(
|
||||
shell.documentOverflow,
|
||||
`${at}: the document scrolls by ${shell.documentOverflow.toFixed(
|
||||
2,
|
||||
)}px — the banner stack pushed the shell past the viewport`,
|
||||
).toBeLessThan(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the banner stack never pushes the shell past the viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Mobile as well as desktop. Below `md` the shell stacks in a column and
|
||||
// there is no sidebar, so there is no seam to measure — but the scroll
|
||||
// model is what hoisting the banners could have broken there, and that
|
||||
// half is measurable at both breakpoints.
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
|
||||
|
||||
const captured = await captureAccountPayload(page);
|
||||
await armSharedRecord(page, captured);
|
||||
await armUnmaintainedLocale(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-slot="maintainership-banner"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="shared-record-banner"]'),
|
||||
).toBeVisible();
|
||||
await armOffline(page);
|
||||
await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible();
|
||||
|
||||
const shell = await readShell(page);
|
||||
expect(shell.bannerCount, "the stack did not arm").toBe(3);
|
||||
expect(shell.bannerHeight).toBeGreaterThan(0);
|
||||
expect(
|
||||
shell.documentOverflow,
|
||||
`the document scrolls by ${shell.documentOverflow.toFixed(
|
||||
2,
|
||||
)}px under a ${shell.bannerHeight.toFixed(2)}px banner stack`,
|
||||
).toBeLessThan(1);
|
||||
|
||||
// The top bar is still fully painted below the stack rather than pushed
|
||||
// off the bottom of the viewport.
|
||||
const top = shell.topBar!;
|
||||
expect(top.height).toBeGreaterThan(32);
|
||||
expect(
|
||||
top.bottom,
|
||||
"the banner stack pushed the top bar out of the viewport",
|
||||
).toBeLessThanOrEqual(shell.viewportHeight);
|
||||
});
|
||||
});
|
||||
@@ -93,10 +93,11 @@ export function MaintainershipBanner() {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-slot="maintainership-banner"
|
||||
data-testid="maintainership-banner"
|
||||
className="border-border bg-muted/40 text-muted-foreground flex items-start gap-2 border-b px-4 py-2 text-xs sm:items-center"
|
||||
>
|
||||
<Languages className="mt-0.5 h-3.5 w-3.5 shrink-0 sm:mt-0" aria-hidden />
|
||||
<Languages className="mt-0.5 size-3.5 shrink-0 sm:mt-0" aria-hidden />
|
||||
<p className="flex-1 leading-snug">
|
||||
{t("i18n.maintainershipBanner.notice")}{" "}
|
||||
<a
|
||||
@@ -117,7 +118,7 @@ export function MaintainershipBanner() {
|
||||
// the surrounding hit area is what the user actually taps.
|
||||
className="hover:text-foreground focus-visible:ring-ring/50 -mr-2 inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -297,67 +297,90 @@ export function AuthShell({
|
||||
{t("nav.skipToContent")}
|
||||
</a>
|
||||
</nav>
|
||||
<div className="flex h-dvh flex-col md:flex-row">
|
||||
<SidebarNav />
|
||||
{/* `min-w-0` lets the content column shrink below its children's
|
||||
intrinsic min width (e.g. the measurements table); without it
|
||||
the column widens past the viewport next to the sidebar and
|
||||
the whole page gains a horizontal scrollbar. Wide children
|
||||
scroll inside their own `overflow-x-auto` containers. */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{/*
|
||||
v1.4.43 QoL (M5) — `<OfflineBanner>` paints only when
|
||||
`navigator.onLine === false`. Sits above the maintainership
|
||||
banner + top bar so the connection-status hint is always
|
||||
the first chrome line a user sees in the offline branch.
|
||||
*/}
|
||||
<OfflineBanner />
|
||||
{/* v1.36.0 — whose record is open. Sits directly under the
|
||||
connection strip and above everything else: a person who has
|
||||
forgotten they are switched is about to write a reading into
|
||||
somebody else's record, and no other line of chrome is worth
|
||||
seeing first. Renders nothing when the session is in its own
|
||||
account, which is every session that has never used sharing. */}
|
||||
<SharedRecordBanner />
|
||||
{/* Demo instances surface a persistent "changes are not
|
||||
saved" strip — the proxy blocks every mutation, and
|
||||
without the banner that block only surfaced as a raw API
|
||||
error after the user already filled in a form. */}
|
||||
{demoMode ? <DemoBanner /> : null}
|
||||
<MaintainershipBanner />
|
||||
<TopBar />
|
||||
<main
|
||||
id="main-content"
|
||||
// `scrollbar-gutter: stable` keeps the scroll viewport's content
|
||||
// box a fixed width whether or not the vertical scrollbar is
|
||||
// painted. Without it a route / sub-tab whose body overflows
|
||||
// (e.g. the admin "Insights Quality" section) shows the bar and
|
||||
// narrows the content box, while a shorter one (e.g.
|
||||
// "Integrations") hides it and widens the box — the `mx-auto`
|
||||
// wrapper then recentres and the whole column, including the
|
||||
// admin/settings sidebar, shifts a few px sideways on every
|
||||
// toggle. Reserving the gutter holds the layout still.
|
||||
//
|
||||
// `relative` makes this element the containing block for every
|
||||
// absolutely-positioned descendant that has no nearer positioned
|
||||
// ancestor. Without it those boxes resolve against the INITIAL
|
||||
// containing block, which lives outside the scroll container, so
|
||||
// `overflow-y-auto` never clips them: their static position sits
|
||||
// wherever the flow put them and extends the DOCUMENT's
|
||||
// scrollable overflow instead, painting a second vertical scroll
|
||||
// surface beside `<main>` (UI-STANDARDS §9 one-scroll-floor).
|
||||
// The class is not hypothetical and not confined to app code. A
|
||||
// Radix `<Switch>` outside a `<form>` renders a hidden
|
||||
// `position:absolute` bubble input on the first pass and drops it
|
||||
// only once the button ref resolves, so every route carrying a
|
||||
// switch below the fold (/settings/modules is the long one) grew
|
||||
// a full-height document scrollbar for the entire pre-hydration
|
||||
// window. Anchoring the scroll container closes the escape route
|
||||
// for all of them at once instead of asking each new
|
||||
// absolutely-positioned child to remember its own wrapper.
|
||||
className="relative flex-1 [scrollbar-gutter:stable] overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
|
||||
>
|
||||
{/*
|
||||
{/*
|
||||
The banner stack is PAGE-level, above the sidebar/content row, and that
|
||||
placement is the whole point rather than a detail of the markup. A
|
||||
banner is a strip about the whole window — the connection is down, this
|
||||
is somebody else's record, this instance saves nothing — and none of
|
||||
those statements is about the content column.
|
||||
|
||||
Inside the column, which is where they lived until v1.36.x, each one
|
||||
pushed the top bar down while the sidebar's logo band stayed at the
|
||||
top. Both bands read `SHELL_HEADER_BAND` so they are the same height
|
||||
with the same border, and the line they draw together still broke: with
|
||||
the French maintainership strip up, the two borders sat 66.5px apart at
|
||||
768px and 61px apart at 1280px, and the full three-banner stack put them
|
||||
184.5px apart. Above the row, every banner displaces both bands by the
|
||||
same amount and the seam cannot come apart however many paint.
|
||||
|
||||
The stack comes OUT of the viewport budget rather than being added on
|
||||
top of it: the outer wrapper owns the `h-dvh`, the row below takes what
|
||||
the banners leave (`min-h-0 flex-1`), and `<main>` stays the one
|
||||
vertical scroller. Measured in `e2e/chrome-header-seam-banners.spec.ts`
|
||||
at both breakpoints.
|
||||
*/}
|
||||
<div className="flex h-dvh flex-col">
|
||||
{/*
|
||||
v1.4.43 QoL (M5) — `<OfflineBanner>` paints only when
|
||||
`navigator.onLine === false`. Sits above the maintainership
|
||||
banner + top bar so the connection-status hint is always
|
||||
the first chrome line a user sees in the offline branch.
|
||||
*/}
|
||||
<OfflineBanner />
|
||||
{/* v1.36.0 — whose record is open. Sits directly under the
|
||||
connection strip and above everything else: a person who has
|
||||
forgotten they are switched is about to write a reading into
|
||||
somebody else's record, and no other line of chrome is worth
|
||||
seeing first. Renders nothing when the session is in its own
|
||||
account, which is every session that has never used sharing. */}
|
||||
<SharedRecordBanner />
|
||||
{/* Demo instances surface a persistent "changes are not
|
||||
saved" strip — the proxy blocks every mutation, and
|
||||
without the banner that block only surfaced as a raw API
|
||||
error after the user already filled in a form. */}
|
||||
{demoMode ? <DemoBanner /> : null}
|
||||
<MaintainershipBanner />
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<SidebarNav />
|
||||
{/* `min-w-0` lets the content column shrink below its children's
|
||||
intrinsic min width (e.g. the measurements table); without it
|
||||
the column widens past the viewport next to the sidebar and
|
||||
the whole page gains a horizontal scrollbar. Wide children
|
||||
scroll inside their own `overflow-x-auto` containers. */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<TopBar />
|
||||
<main
|
||||
id="main-content"
|
||||
// `scrollbar-gutter: stable` keeps the scroll viewport's content
|
||||
// box a fixed width whether or not the vertical scrollbar is
|
||||
// painted. Without it a route / sub-tab whose body overflows
|
||||
// (e.g. the admin "Insights Quality" section) shows the bar and
|
||||
// narrows the content box, while a shorter one (e.g.
|
||||
// "Integrations") hides it and widens the box — the `mx-auto`
|
||||
// wrapper then recentres and the whole column, including the
|
||||
// admin/settings sidebar, shifts a few px sideways on every
|
||||
// toggle. Reserving the gutter holds the layout still.
|
||||
//
|
||||
// `relative` makes this element the containing block for every
|
||||
// absolutely-positioned descendant that has no nearer positioned
|
||||
// ancestor. Without it those boxes resolve against the INITIAL
|
||||
// containing block, which lives outside the scroll container, so
|
||||
// `overflow-y-auto` never clips them: their static position sits
|
||||
// wherever the flow put them and extends the DOCUMENT's
|
||||
// scrollable overflow instead, painting a second vertical scroll
|
||||
// surface beside `<main>` (UI-STANDARDS §9 one-scroll-floor).
|
||||
// The class is not hypothetical and not confined to app code. A
|
||||
// Radix `<Switch>` outside a `<form>` renders a hidden
|
||||
// `position:absolute` bubble input on the first pass and drops it
|
||||
// only once the button ref resolves, so every route carrying a
|
||||
// switch below the fold (/settings/modules is the long one) grew
|
||||
// a full-height document scrollbar for the entire pre-hydration
|
||||
// window. Anchoring the scroll container closes the escape route
|
||||
// for all of them at once instead of asking each new
|
||||
// absolutely-positioned child to remember its own wrapper.
|
||||
className="relative flex-1 [scrollbar-gutter:stable] overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
|
||||
>
|
||||
{/*
|
||||
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
|
||||
(1280 px) so dashboard / settings / admin all hit the
|
||||
same content frame. Pre-v1.4.33 the dashboard
|
||||
@@ -371,15 +394,16 @@ export function AuthShell({
|
||||
the desktop bottom-6 anchor), so the last line of content
|
||||
can always scroll out from under the floating button.
|
||||
*/}
|
||||
<div
|
||||
data-slot="main-content-wrapper"
|
||||
className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
|
||||
>
|
||||
{outsideSharedRecord ? <SharedRecordUnavailable /> : children}
|
||||
</div>
|
||||
</main>
|
||||
<div
|
||||
data-slot="main-content-wrapper"
|
||||
className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
|
||||
>
|
||||
{outsideSharedRecord ? <SharedRecordUnavailable /> : children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
<LayoutCoachMount />
|
||||
{/* v1.18.6 — the module-tour launcher lives at the shell level so its
|
||||
|
||||
@@ -31,7 +31,7 @@ export function DemoBanner() {
|
||||
data-slot="demo-banner"
|
||||
className="bg-primary/10 border-primary/30 text-foreground flex items-center justify-center gap-2 border-b px-3 py-2 text-xs"
|
||||
>
|
||||
<FlaskConical className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<FlaskConical className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="text-center">{t("demoBanner.message")}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -83,7 +83,7 @@ export function OfflineBanner() {
|
||||
data-slot="offline-banner"
|
||||
className="bg-warning/15 border-warning/40 text-foreground flex items-center justify-center gap-2 border-b px-3 py-2 text-xs"
|
||||
>
|
||||
<WifiOff className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<WifiOff className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="text-center">
|
||||
{t("offlineBanner.message")}
|
||||
{hasCachedData ? ` ${t("offlineBanner.showingCached")}` : ""}
|
||||
|
||||
@@ -30,7 +30,18 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
*
|
||||
* The name is `text-foreground` and the qualifier `text-muted-foreground`, per
|
||||
* the two-tier text rule — the person's name is content, the read-only note is
|
||||
* meta. Mounted for every authenticated route inside `<AuthShell>` (the
|
||||
* meta.
|
||||
*
|
||||
* The row is the inline-action shape from UI-STANDARDS §11 rather than a
|
||||
* centred wrapping stack: sentence `min-w-0 flex-1`, action `shrink-0`, so the
|
||||
* way out stays beside the sentence instead of dropping onto its own line once
|
||||
* the name is long. `<MaintainershipBanner>` is the sibling that already had an
|
||||
* action and already had this shape; the two now read the same. The tap target
|
||||
* carries the standard's floor (`min-h-11 sm:min-h-9`) — a person leaving
|
||||
* somebody else's record on a phone is the one interaction in the stack, and it
|
||||
* was a 32px target.
|
||||
*
|
||||
* Mounted for every authenticated route inside `<AuthShell>` (the
|
||||
* `<DemoBanner>` pattern) so no page can forget it, and rendered from
|
||||
* `accountAccess.active`, which the server resolves: the banner appears exactly
|
||||
* when the server says the session is inside a record, never on a client guess.
|
||||
@@ -54,25 +65,30 @@ export function SharedRecordBanner() {
|
||||
role="status"
|
||||
data-slot="shared-record-banner"
|
||||
data-account-id={active.accountId}
|
||||
className="bg-warning/15 border-warning/40 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b px-3 py-2 text-xs"
|
||||
className="bg-warning/15 border-warning/40 flex items-start gap-3 border-b px-3 py-2 text-xs sm:items-center"
|
||||
>
|
||||
<Eye className="text-foreground size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="text-foreground text-center font-medium">
|
||||
{t("recordSharing.banner.viewing", { name })}
|
||||
</span>
|
||||
{/* Resolved server-side. `canWrite` is false for every grant in v1, and
|
||||
the day it is not, this line stops claiming otherwise on its own. */}
|
||||
{!active.canWrite && (
|
||||
<span className="text-muted-foreground text-center">
|
||||
{t("recordSharing.banner.readOnly")}
|
||||
</span>
|
||||
)}
|
||||
<Eye
|
||||
className="text-foreground mt-0.5 size-3.5 shrink-0 sm:mt-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="min-w-0 flex-1 leading-snug">
|
||||
<span className="text-foreground font-medium">
|
||||
{t("recordSharing.banner.viewing", { name })}
|
||||
</span>{" "}
|
||||
{/* Resolved server-side. `canWrite` is false for every grant in v1, and
|
||||
the day it is not, this line stops claiming otherwise on its own. */}
|
||||
{!active.canWrite && (
|
||||
<span className="text-muted-foreground">
|
||||
{t("recordSharing.banner.readOnly")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="shared-record-banner-exit"
|
||||
onClick={() => switchAccount.mutate(null)}
|
||||
disabled={switchAccount.isPending}
|
||||
className="text-foreground hover:bg-warning/25 focus-visible:ring-ring inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-md px-2 py-1 font-medium underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
className="text-foreground hover:bg-warning/25 focus-visible:ring-ring inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-md px-2 py-1 font-medium underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 sm:min-h-9"
|
||||
>
|
||||
{switchAccount.isPending ? (
|
||||
<Loader2
|
||||
|
||||
Reference in new issue
Block a user