Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
83a6392
fix(pwa): precache every emitted chunk so lazy views load on a first …
do-ops885 Sep 30, 2026
04676c2
chore(deps): refresh Next to 16.3.7 and the React runtime to 19.3.0
do-ops885 Sep 30, 2026
58addb7
docs(plans): reconcile roadmap records and add Plan 162
do-ops885 Oct 5, 2026
609ae90
docs(plans): record reproduction of the recovery/backup-outcome gap
do-ops885 Oct 5, 2026
0ebe5a3
feat(studio): make the pre-import backup truthful and restorable
do-ops885 Oct 9, 2026
eb1f44f
fix(a11y,offline): reserve the offline banner's measured height so it…
do-ops885 Oct 10, 2026
cddfc14
fix(a11y): make useReducedMotion hydration-safe via useSyncExternalStore
do-ops885 Oct 10, 2026
a89647b
ci: gate the production offline suite behind an `offline` paths filte…
do-ops885 Oct 10, 2026
3a0b4ee
docs(plans): record both Plan 161 follow-on fixes and the CI offline …
do-ops885 Oct 10, 2026
1a4d2d8
fix(review): address all GitNexus findings on the PR #925 batch
do-ops885 Oct 10, 2026
e41cd96
fix(review): close the three GitNexus findings on this batch
do-ops885 Oct 12, 2026
3b0019b
chore(deps): reconcile the lockfile with main's resolved tree
do-ops885 Oct 12, 2026
a53dacf
chore(pwa): regenerate the precache manifest from this tree
do-ops885 Oct 12, 2026
515359c
test(offline): prove the banner exit ordering at frame resolution
do-ops885 Oct 12, 2026
6646d53
chore(pwa): rebuild the precache manifest from the clean tree
do-ops885 Oct 12, 2026
61ce583
fix(studio): report refused snapshot clear and reserve banner height …
do-ops885 Oct 12, 2026
604cd22
fix(studio): clear the stale snapshot when serialization fails, and c…
do-ops885 Oct 12, 2026
ecb1f7c
fix(studio): tell the user when a restore left the snapshot behind, a…
do-ops885 Oct 12, 2026
c32b1be
fix(studio): stop promising the snapshot is from the last import, and…
do-ops885 Oct 12, 2026
8dab6e6
fix(pwa): serve the .avif files the manifest precaches
do-ops885 Oct 12, 2026
4edf187
fix(pwa,offline): serve the web manifest offline and keep measuring t…
do-ops885 Oct 12, 2026
76cc8bf
test(pwa): check the generator's code extensions in the parity guard
do-ops885 Oct 12, 2026
a5ec93e
Merge branch 'main' into fix/offline-lazy-view-precache
d-oit Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions .github/workflows/ci-and-labels.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions .github/workflows/security-scan.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

197 changes: 197 additions & 0 deletions e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
expectNavigationReachable,
navClick,
openNavIfHidden,
waitForAppReady,
} from './helpers/navigation';

test.describe('Accessibility', () => {
Expand Down Expand Up @@ -193,3 +194,199 @@ test.describe('axe-core automated accessibility', () => {
await assertNoAxeViolations(page);
});
});

/**
* The offline banner is `fixed top-0` at `z-50` while the topbar is in normal
* flow at the top of the page, so the banner's band sits directly over the
* topbar's controls.
*
* Measured before this test existed: on a 1280 and a 1920 viewport the 36px
* banner covered the hit points of the quick filter, the command-palette
* trigger and New entity; at 390×844 the wrapped 56px banner covered the menu
* trigger and the search trigger too. `document.elementFromPoint` returned the
* banner for every one of them, so while offline — the moment a local-first app
* most needs to work — its primary controls could not be clicked.
*
* This asserts the user-visible contract (the controls are hit-testable), not
* the banner's height or styling, so any layout that keeps them reachable
* passes.
*/
test.describe('Offline banner', () => {
// The banner slides in from above the viewport. Without this, the geometric
// probe below can run while it is still off-screen and every control looks
// reachable — the exact way the first version of this test passed on broken
// code. Reduced motion removes the slide entirely, so the probe always sees
// the resting layout, and the explicit position wait covers the animated case.
test.use({ reducedMotion: 'reduce' });
Comment thread
d-oit marked this conversation as resolved.

test('does not cover the topbar controls', async ({ page, context }) => {
await page.goto('/');
await waitForAppReady(page);

await context.setOffline(true);

// The banner mounts and the shell reserves its height in the SAME paint
// (OfflineIndicator publishes `--offline-banner-height` from a layout
// effect), so under reduced motion the reservation is already in place when
// the banner first appears. The poll is kept as a precondition/diagnostic
// rather than to paper over a frame gap: it reports "only 0px of 56px
// reserved" instead of an inscrutable geometric failure if the reservation
// ever regresses, and it also covers the non-reduced-motion slide-in.
await expect
.poll(
() =>
page.evaluate(() => {
const banner = [...document.querySelectorAll('[role="status"]')].find(
(node) => /offline/i.test(node.textContent ?? '') && node.className.includes('fixed'),
);
if (banner === undefined) return 'no banner';
const bannerRect = banner.getBoundingClientRect();
// The banner is `fixed top-0`, so at rest its top is 0. While it
// slides in from above, top is NEGATIVE (bottom < height) — which a
// bottom-only check treated as "at rest", letting the geometric
// probe below run against a half-entered banner (GitNexus on
// PR #925).
if (Math.abs(bannerRect.top) > 0.5) return 'banner still sliding in';
const shell = document.querySelector('[data-app-ready="true"]');
const reserved = shell ? Number.parseFloat(getComputedStyle(shell).paddingTop) : 0;
return reserved >= bannerRect.height - 0.5
? 'reserved'
: `only ${reserved}px of ${bannerRect.height}px reserved`;
}),
{ timeout: 5000 },
)
.toBe('reserved');

const obstructed = await page.evaluate(() =>
[...document.querySelectorAll('header button, header input')]
.map((el) => ({ el, rect: el.getBoundingClientRect() }))
.filter(({ rect }) => rect.width > 0)
.filter(({ el, rect }) => {
const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
return !(hit === el || el.contains(hit));
})
.map(({ el }) =>
(el.getAttribute('aria-label') ?? el.getAttribute('placeholder') ?? el.textContent ?? '')
.trim()
.slice(0, 40),
),
);

expect(
obstructed,
`topbar controls hidden behind the offline banner: ${obstructed.join(', ')}`,
).toEqual([]);
});

});

/**
* The reservation's ORDERING claim needs the exit animation to exist: under the
* reduced-motion override above, the banner unmounts in the same frame the
* reservation drops, so a regression that releases early has no window to be
* caught in (measured: a defect injected into `offline-indicator.tsx` passed the
* reduced-motion version of this test). This block therefore runs with motion
* enabled and records the two events from inside the page.
*/
test.describe('Offline banner exit', () => {
test.use({ reducedMotion: 'no-preference' });

test('releases the reserved space once the banner has exited', async ({ page, context }) => {
await page.goto('/');
await waitForAppReady(page);

await context.setOffline(true);
// Wait for the banner to be *in view*, not merely mounted: with motion
// enabled it enters from `y: -100%`, so a recorder started as soon as the
// reservation appears would sample a banner that is still above the
// viewport, record `banner-gone` before the online transition, and then
// accept a premature release during the real exit (GitNexus on PR #925).
await expect
.poll(
() =>
page.evaluate(() => {
const shell = document.querySelector('[data-app-ready="true"]');
const banner = [...document.querySelectorAll('[role="status"]')].find(
(node) => /offline/i.test(node.textContent ?? '') && node.className.includes('fixed'),
);
if (shell === null) return 'no shell';
if (banner === undefined) return 'no banner';
const rect = banner.getBoundingClientRect();
const reserved = Number.parseFloat(getComputedStyle(shell).paddingTop);
if (reserved <= 0) return 'not reserved';
if (rect.bottom <= 0 || rect.height === 0) return 'banner still entering';
return 'in view and reserved';
}),
{ timeout: 5000 },
)
.toBe('in view and reserved');

// Record the ORDER of the two events from inside the page, at frame
// resolution. The claim is causal — the reservation must outlive the
// banner's exit — and neither a poll loop from the test process nor a
// reduced-motion run can see it: the exit is a few frames long, so a
// regression that drops the reservation the moment the browser goes online
// slips between samples (measured: exactly that defect passed the previous
// version of this test).
//
// The recorder runs inside the page and resolves once it has seen both
// events, so no state crosses the boundary except the ordered list.
const recording = page.evaluate(async () => {
Comment thread
d-oit marked this conversation as resolved.
// "Gone" is a geometry question, not a DOM-presence one: during the exit
// animation the banner is still mounted while sliding off the top of the
// viewport, and a reservation dropped at that point covers nothing. The
// contract is that the topbar is never left under a *visible* banner, so
// the marker fires once the banner's bottom edge has left the viewport.
const bannerOnScreen = () =>
[...document.querySelectorAll('[role="status"]')].some((node) => {
if (!/offline/i.test(node.textContent ?? '') || !node.className.includes('fixed')) {
return false;
}
return node.getBoundingClientRect().bottom > 0;
});
const reservedPx = () => {
const shell = document.querySelector('[data-app-ready="true"]');
return shell === null ? 0 : Number.parseFloat(getComputedStyle(shell).paddingTop);
};

const recorded: string[] = [];
await new Promise<void>((resolve) => {
let settled = false;
const finish = (marker?: string) => {
if (settled) return;
settled = true;
if (marker !== undefined) recorded.push(marker);
resolve();
};
const tick = () => {
if (!bannerOnScreen() && !recorded.includes('banner-gone')) recorded.push('banner-gone');
Comment thread
d-oit marked this conversation as resolved.
if (reservedPx() === 0 && !recorded.includes('unreserved')) recorded.push('unreserved');
if (recorded.includes('banner-gone') && recorded.includes('unreserved')) {
finish();
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
setTimeout(() => { finish('timeout'); }, 5000);
});
return recorded;
});

await context.setOffline(false);

const order = await recording;
const bannerGoneAt = order.indexOf('banner-gone');
const unreservedAt = order.indexOf('unreserved');

expect(bannerGoneAt, `the banner never left the layout: ${order.join(' -> ')}`).toBeGreaterThanOrEqual(0);
expect(unreservedAt, `never released the reservation: ${order.join(' -> ')}`).toBeGreaterThanOrEqual(0);
// The reservation may end in the same frame the banner leaves, but never
// before it: releasing first slides the topbar back under a banner that is
// still animating out.
expect(
unreservedAt,
`reservation released before the banner left the layout: ${order.join(' -> ')}`,
).toBeGreaterThanOrEqual(bannerGoneAt);
});
});
65 changes: 65 additions & 0 deletions e2e/hydration-mismatch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady } from './helpers/navigation';

/**
* A view must hydrate cleanly when the OS asks for reduced motion.
*
* `useReducedMotion` is consumed by ~25 animated elements to decide whether to
* skip their entry animation (`initial={reducedMotion ? false : {...}}`). If the
* hook answers differently on the server than on the client's first pass, the
* markup React hydrates does not match the HTML it was sent, and React reports
* a mismatch and re-renders that subtree from scratch — on every animated
* element, on every load, for exactly the users who asked for less motion.
*
* The failure is invisible to a screenshot and invisible to a unit test that
* mocks the hook, so it is pinned here in a real browser instead.
*
* Runs at every configured viewport, because the media state is per-context
* rather than per-viewport and a view can be entered by reloading on it.
*/

/** Readable React message in development; minified codes are #418/#423 in production. */
const HYDRATION_MISMATCH = /hydrat|server rendered HTML didn't match|Minified React error #(418|423)/i;

/**
* React reports a hydration mismatch *after* the tree has committed: the report
* is queued, not thrown, so it arrives some time after `data-app-ready` — and an
* absence assertion cannot poll for a message that never comes. Measured on the
* defective code: reading the console immediately after readiness captures
* nothing, and the report is present well within this window.
*/
const HYDRATION_REPORT_SETTLE_MS = 2000;

test.describe('Hydration under prefers-reduced-motion', () => {
test.use({ reducedMotion: 'reduce' });

test('Home hydrates without a server/client mismatch', async ({ page }) => {
const consoleOutput: string[] = [];
page.on('console', (message) => {
consoleOutput.push(`[${message.type()}] ${message.text()}`);
});
page.on('pageerror', (error) => consoleOutput.push(`[pageerror] ${error.message}`));

// The listener must exist before the document loads: React reports the
// mismatch while hydrating, which is over by the time `goto` resolves.
await page.goto('/');
await waitForAppReady(page);
await page.waitForTimeout(HYDRATION_REPORT_SETTLE_MS);

// Sanity: a blank page must not be able to pass this test.
await expect(page.getByRole('heading', { name: /recent work/i })).toBeVisible();

const mismatches = consoleOutput.filter((text) => HYDRATION_MISMATCH.test(text));
expect(mismatches, `hydration errors:\n${mismatches.join('\n')}`).toEqual([]);
});

test('entry animations end visible rather than frozen mid-motion', async ({ page }) => {
await page.goto('/');
await waitForAppReady(page);

// The section the Home view fades in. Under reduced motion it must reach
// full opacity immediately — the point of skipping the animation is to end
// at the final state, not to sit at the initial one.
await expect(page.locator('main section').first()).toHaveCSS('opacity', '1');
});
});
Loading
Loading