Skip to content

feat: navigation, loading/error states, and generation UX (D2) - #52

Merged
datj9 merged 7 commits into
mainfrom
feat/navigation-and-generation-ux
Sep 24, 2026
Merged

datj9 merged 7 commits into
mainfrom
feat/navigation-and-generation-ux

Conversation

@datj9

@datj9 datj9 commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Implements plan section D2 feat/navigation-and-generation-ux. Touches only D2-owned paths plus new files in them (app/_components/{submit-button,submit-gate,list-skeleton}*, app/status-page.module.css, app/new/stream-{batch,view}.ts, src/lib/format/bytes.ts) and new unit tests.

Changes per item

1. next/link for internal links

  • Dashboard header (New artifact / Trash / Settings / Admin), empty-state CTA, artifact rows; trash "Back to artifacts"; /new wordmark and "Open artifact"; auth footers (Forgot password?, Back to sign in); error/not-found pages.
  • The OIDC button (/api/auth/oidc/start) stays a plain <a> on purpose. It points at a route handler that redirects to the IdP, so it needs a full document navigation. A comment explains why.

2. Loading / error / not-found

  • app/dashboard/loading.tsx and app/trash/loading.tsx render a shared ListSkeleton: the same header bar and a column of rows, with an opacity-only linear shimmer. It's functional motion, so per docs/motion.md it keeps running under reduced motion. It has no links or headings, so tests and screen readers can't pick up duplicates, plus an sr-only status line and aria-busy on <main>.
  • The root app/error.tsx uses Next 16.3's stable retry(), which re-fetches the segment (reset() would just show the same server error again). It shows error.digest as a reference, focuses the heading, and offers "Try again" and "Back to artifacts".
  • not-found.tsx: the inline styles move to a CSS module shared with error.tsx. It links to /, not /dashboard, because anonymous share-link visitors land here too.

3. Generation flow (app/new/**)

  • While a stream runs, the textarea is readOnly and the Generate, starter and Retry buttons are aria-disabled, not disabled, so focus doesn't drop to <body>. Handlers check isStreaming.
  • When generation finishes, focus moves to the result panel. The panel is now a role="region" labelled by a new "Artifact ready" <h2>, so the next Tab reaches "Open artifact".
  • ⌘/Ctrl+Enter submits through form.requestSubmit(), so it passes the same guard as a click. It's ignored during IME composition. There's a visible hint (aria-describedby) and aria-keyshortcuts.
  • A beforeunload guard is active only while streaming.
  • A polite sr-only live region announces " written, " for each completed file. The text is derived from state, not set from an effect: each file_end changes the text, so each file is announced once.
  • memo(FilePanel): the reducer keeps the identity of files it didn't touch, so only the file being written re-renders.
  • createActionBatcher: chunks are queued and flushed once per animation frame, and consecutive chunks for the same path are merged when they're queued. Every non-chunk action flushes the queue first and is dispatched right away, so order is kept and ✓ / result / failure never wait for a frame. Every exit path (end of stream, error, abort) flushes before its own dispatch.
  • Stick-to-bottom only happens while the reader is within 48px of the bottom. Scrolling back down turns it on again, and each new generation resets it. It uses a layout effect so the scroll lands in the same frame as the new text.

4. Shared formatBytes (src/lib/format/bytes.ts)

  • Same behaviour as the MB-aware dashboard version, used in prompt-composer and artifact-list. One small difference: it promotes to MB based on the rounded figure, so 1 048 575 B reads "1.0 MB" instead of "1024.0 KB". NaN or negative input shows as "0 B".

5. Trash

  • The Restore button says "Restoring" on the row being restored.
  • After a restore, a permanently mounted role="status" line above the list says "Restored <title>. Open it" and links to /a/{id}. It lives in local state outside the list, so it survives router.refresh() removing the row, including when the list becomes empty.

6. Auth POST forms: SubmitButton

  • useFormStatus only reports pending for forms whose action is a function. These forms post to URLs and navigate natively, so it would never fire on its own. SubmitButton therefore attaches a submit listener to its form. The first submit goes through, later ones are preventDefaulted, and the lock reopens on a bfcache pageshow. useFormStatus is still combined in for any future server-action form. submit only fires after constraint validation, so an invalid form never locks itself. Before hydration the form works normally but has no guard.
  • The pending labels ("Signing in", "Creating account", "Sending", …) use aria-disabled.
  • It's used by AuthScreen, so setup, signin, signup, forgot-password and reset-password all get it, plus the dashboard's sign-out form.

Accessibility notes

  • No control is disabled mid-action anywhere in these flows. Every aria-disabled button keeps focus, and its handler checks the state.
  • Live regions are mounted empty and then filled (composer file status, trash restore status), following the repo's existing convention.
  • Programmatic focus targets (result panel, error heading) hide the ring only for :focus:not(:focus-visible).

Testing

  • pnpm typecheck ✅, pnpm lint ✅, pnpm vitest run --project unit ✅ (76 files / 1205 tests), and pnpm build ✅ with a dummy env.
  • New unit tests:
    • format-bytes.test.ts
    • generation-stream-batch.test.ts: frame batching, per-path merge, ordering, flush/dispose, one frame per batch
    • generation-stream-view.test.ts: near-bottom threshold, announcement text, ⌘/Ctrl+Enter and IME
    • submit-gate.test.ts
  • Not run locally: e2e (no Docker). Nothing here was exercised in a real browser. Keyboard, focus and screen-reader behaviour is untested beyond typecheck, lint and build.

E2E specs touched

zz-design-system.spec.ts only (see the follow-up section). Otherwise none: I checked the specs that use these pages:

  • start-a-generation: link names are unchanged.
  • trash-delete-restore: it filters by trash-row, and the new status line sits outside those rows.
  • signin-password-reset: getByRole('status') on forgot-password still matches only the success message. SubmitButton adds no status role, and the href="/forgot-password" attribute is unchanged under next/link.
  • setup-and-signin, upload-and-list, zz-design-system: button names are only matched before clicking.
  • No spec asserted toBeDisabled on the composer.

Follow-up: signed-out redirect and design-system spec (commit 031aa27)

  • App: loading.tsx wraps the page in Suspense, so the page's redirect('/signin') was streamed after the skeleton and carried out client-side after load. Signed-out visitors saw a skeleton and then a second navigation. app/dashboard/layout.tsx and app/trash/layout.tsx now check the session outside that boundary through app/_components/session-gate.ts (requireSessionUser), so the redirect is a real HTTP one. The lookup goes through React cache(), so the layout and page share one query. The pages keep their own check. Only dashboard and trash have loading states.
  • Spec (unowned: tests/e2e/zz-design-system.spec.ts, renamed to design-system.spec.ts by PR chore(ci): harden CI, coverage and e2e ordering #55): signIn used the standalone request fixture, which doesn't share cookies with page. So every signed-in surface was actually measuring /signin after the server redirect, a bug that predates this PR. It now signs in through page.request. That's the minimal change: 4 call sites plus a doc comment. Expect a trivial conflict with chore(ci): harden CI, coverage and e2e ordering #55's rename.
  • Consequence: the signed-in pages (dashboard, new, trash, settings/, admin/) are measured for the first time. From reading the code, the D2-owned pages (dashboard, /new, trash) stay at or under 4 type sizes, their controls are at least 44px on touch, and they don't overflow at 320px. I couldn't verify the settings/admin pages (D1b-owned) without running e2e. If CI shows real violations there, they should be fixed rather than loosening the assertions.
  • A side effect: once signed in, /signin and /forgot-password in PUBLIC_PAGES redirect to /dashboard in the two combined loops, so those iterations measure the dashboard. The standalone public-page tests still measure the real pages.

Risks / notes

  • Soft navigation to /a/{id} now goes through next/link (dashboard rows, "Open artifact", trash restore link). The artifact page renders the same way either way, but any spec that clicks a row and relied on a full document load would be affected. I found none.
  • Leaving /new mid-stream through next/link (the wordmark) doesn't fire beforeunload. The fetch isn't aborted on unmount either, so the generation still finishes server-side and shows up on the dashboard. A full unload does abort it (the server stops on request.signal), and that's what the guard covers.
  • Marketing links (app/_components/marketing/*: nav, CTA, colophon → /signin) are still plain <a>. They live outside my owned paths (only app/(marketing)/** is mine, and it has no internal links).
  • prettier --check still flags two pre-existing files in app/_components/marketing/ that I didn't touch.
  • SubmitButton's lock depends on the POST ending in a navigation, which is true for every route it's used with. A form whose response doesn't navigate (a 204 or a download) would stay locked until the next page show.

🤖 Generated with Claude Code

datj9 and others added 5 commits September 23, 2026 06:47
The composer and the dashboard each carried a byte formatter, and the
composer's stopped at KB. One implementation in src/lib/format, promoting
on the rounded figure so 1 048 575 bytes reads "1.0 MB", not "1024.0 KB".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- aria-disabled / readOnly instead of disabled while streaming, so focus
  is not dropped to <body> when Generate is pressed
- focus the result panel (now a labelled region) when generation finishes
- Cmd/Ctrl+Enter submits from the prompt (IME-safe), with a visible hint
- beforeunload guard while a stream is running
- polite live announcement per completed file
- memo(FilePanel) and rAF-batched, per-file-coalesced chunk dispatch
- stick to the bottom only while the reader is already near it
- shared formatBytes; next/link for the wordmark and Open artifact

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The auth screens post plain HTML forms to route handlers, so a double
click sent two requests (a second rate-limit slot, a second reset email).
useFormStatus cannot see URL-action forms, so SubmitButton latches on the
form's submit event (reopened on bfcache restore), shows a pending label,
and uses aria-disabled so focus stays put. Footer links use next/link;
the OIDC start link stays a plain <a> because it targets a route handler.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- next/link for internal links on the dashboard and trash pages
- loading.tsx skeletons for /dashboard and /trash (opacity shimmer)
- root error.tsx with retry(), digest reference, and focus on the heading
- not-found.tsx moves to a CSS module and links home
- trash: "Restoring" busy label; after restore, a status line linking to
  the restored artifact that survives the router.refresh()
- dashboard sign-out uses SubmitButton

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
loading.tsx wraps the page in Suspense, so the page's redirect('/signin')
was streamed after the skeleton and performed client-side after load. A
signed-out visitor saw a skeleton then a second navigation, and the
design-system e2e spec's page.evaluate died with "Execution context was
destroyed". Dashboard and trash now get a layout.tsx that checks the session
outside the boundary (a real HTTP redirect); the lookup is memoised per
request with React cache() so layout + page cost one query.

The spec signed in through the standalone `request` fixture, whose cookies
are not shared with `page`, so every signed-in surface silently measured
/signin. It now signs in through page.request (minimal change; the file is
owned by PR #55, which renames it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@datj9 datj9 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review (Claude Code)

Verdict: fix the HIGH before merging. The rest of the PR is careful work. The batcher ordering, the aria-disabled guards, the layout-level session gate and the error boundary all hold up when I read them.

Findings: 0 CRITICAL, 1 HIGH, 1 MEDIUM, 3 LOW

Checks run locally on 031aa27: pnpm typecheck passed, pnpm lint passed, pnpm vitest run --project unit passed (76 files, 1205 tests). I did not run e2e or a browser.

Summary

  1. HIGH app/dashboard/artifact-list.tsx:102 (and app/new/prompt-composer.tsx:88, trash "Open it"). Soft navigation to /a/{id} puts a single-use, 30 s handoff token into the client router cache. Browser Back/Forward replays that cached token, so the artifact iframe fails to load. Details are inline.
  2. MEDIUM app/trash/trash-list.tsx:44. busyId is cleared before router.refresh() lands. A second Restore click on the same row during that window wipes the success line and shows a false "could not be restored".
  3. LOW app/_components/submit-button.tsx:48. If the user stops the navigation (Esc or the Stop button), the lock never reopens. The button stays on "Signing in" and refuses every later submit until a reload.
  4. LOW app/error.tsx:53. The root error boundary also covers anonymous routes (/s/[token], /a/[id] for share visitors), so "Back to artifacts" sends them to /dashboard, which then redirects to /signin. not-found.tsx already links to / for exactly this reason.
  5. LOW Test gaps. No test covers the new UI states: the trash restored-status line and its link, focus moving to the result panel, the SubmitButton lock (only the pure latch is tested), and the layout-level redirect. The PR says e2e wasn't run, so none of the keyboard, focus or live-region behaviour has been exercised yet. One e2e assertion each for trash-restored-link and the generation-result focus would cover the new contract.

Comment thread app/dashboard/artifact-list.tsx Outdated
}}
>
<a className={styles.link} href={`/a/${item.id}`}>
<Link className={styles.link} href={`/a/${item.id}`}>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH: this breaks the artifact view on browser Back/Forward.

/a/[id]/page.tsx mints a handoff token (signHandoffToken: HANDOFF_TTL_SECONDS = 30, single-use via consumedTokenIds) and bakes it into the iframe src (__enter?t=...). With next/link, the RSC payload for /a/{id} goes into the client router cache. App Router back/forward navigation restores from that cache without refetching, and staleTimes don't apply to back/forward.

How it fails:

  1. Dashboard, then click a row (soft nav). The token is consumed by the iframe load.
  2. Press browser Back (dashboard), then Forward.
  3. /a/{id} is restored from cache and the iframe remounts with the consumed or expired token. The handoff rejects it, so the artifact doesn't load.

Before this PR the row was a plain <a>, so this flow was a full document load (or bfcache with the iframe still alive). The same applies to "Open artifact" in app/new/prompt-composer.tsx:88 and the trash "Open it" link. The PR's risk note ("renders the same either way") misses the back/forward case.

Fix options: keep plain <a> for links into /a/{id} (the same reasoning as the OIDC link), or mint the token client-side or on mount (for example a route handler the frame calls) so it is never part of a cacheable RSC payload.

Comment thread app/trash/trash-list.tsx
setBusyId(artifactId)
setBusyId(item.id)
setErrorMessage(null)
setRestored(null)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: router.refresh() isn't awaited (it is a transition), so finally (line 57) clears busyId while the restored row is still on screen. During that window (a force-dynamic page plus a DB read, so a few hundred ms) the row shows "Restore" and is enabled again.

A second click on the same row calls setRestored(null) here, and the POST fails because the artifact is already restored. The user then sees "Its restore window may have run out" for an artifact that was just restored, and the new "Restored ... Open it" line is gone.

Fix: keep the row busy until the refresh lands, for example startTransition(() => router.refresh()) combined with useTransition's isPending, or hide or lock rows whose id matches restored.id.

event.preventDefault()
return
}
setIsSubmitting(true)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW: the lock only reopens on pageshow with persisted. If the user cancels the in-flight POST (Esc, or the browser Stop button during a slow sign-in), no navigation happens and no pageshow fires. The button stays "Signing in", aria-disabled, and every later submit is preventDefaulted until a manual reload.

A cheap way out: reopen after a timeout (for example 10 to 15 s), or on the next input or change event in the form, so a stalled submit can be retried.

Comment thread app/error.tsx
<button className="button-primary" type="button" onClick={() => retry()}>
Try again
</button>
<Link className="button-secondary" href="/dashboard">

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW: this root boundary also catches errors on anonymous surfaces (/s/[token], /a/[id] for share-link and org viewers). For those visitors, "Back to artifacts" goes to /dashboard, and the layout gate redirects them to /signin. not-found.tsx links to / for exactly this reason, so consider the same target here, or choosing the target based on whether a session cookie exists.

- Dashboard rows, "Open artifact" and the trash restore link go back to plain <a>. The viewer
  page embeds a single-use handoff token in the iframe URL, and the App Router replays the
  cached render on Back/Forward, so a soft-navigated /a/{id} could re-present a burnt token
  and show the re-entry page.
- design-system spec: the combined desktop/typography loops measured /signin and
  /forgot-password while signed in, which redirects to /dashboard. Public pages are now
  measured after clearing cookies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@datj9

datj9 commented Sep 23, 2026

Copy link
Copy Markdown
Owner Author

Review summary (D2)

Fixed in 76b10a6

  • PLAUSIBLE, medium: /a/{id} links moved to next/link (dashboard rows, "Open artifact", trash restore link). The viewer page puts a single-use handoff token (30s TTL) in the iframe URL. The App Router serves the cached render again on browser Back/Forward, so dashboard -> row (soft) -> Back -> Forward would load the iframe with an already-used token and show "This artifact needs to be reopened". Before this PR these were full loads, which always mint a fresh token. They're plain <a> again, with a comment. The CSP is the same on every app page, so CSP wasn't the issue. Not reproduced in a browser.
  • CONFIRMED, test coverage: in zz-design-system.spec.ts, the combined desktop and typography loops now sign in before visiting /signin and /forgot-password. Both redirect signed-in users to /dashboard, so those two public pages were no longer measured anywhere at desktop width or for type sizes. The loops now measure signed-in pages first, then clear cookies and measure the public pages. Signing in first also completes setup.

Checked, no defect found

  • cache(getSessionUser) is scoped to one request. It is the same function, so a deactivated user or a changed password still gets null. Both pages still call requireSessionUser themselves.
  • error.tsx is a client component and shows only the digest, never error.message. In Next 16.3, retry = router.refresh() + reset.
  • Batcher: every non-chunk action flushes the queue first, so a chunk always lands before its file's file_end. All exit paths flush, and a background tab just builds up one file's text until the next non-chunk action.
  • The reducer's updateFile keeps the identity of files it doesn't touch, so memo(FilePanel) is effective.
  • aria-disabled buttons and ⌘/Ctrl+Enter both go through onSubmit, which checks isStreaming/isPromptEmpty. The beforeunload listener is removed when streaming ends. Focus-on-done runs once per result mount. isNearBottom is fine.
  • SubmitButton: every response to a form POST from these routes navigates. pageshow.persisted reopens the gate. The restore title is escaped by React.
  • formatBytes: NaN, negative and Infinity give "0 B". The boundaries are tested.

Left (minor, not fixed)

  • error.tsx "Back to artifacts" goes to /dashboard, and anonymous share-link visitors (/s/...) who hit an error get bounced to /signin. not-found.tsx links to / for exactly this reason.
  • SubmitButton stays locked if the user presses the browser's Stop during the POST. There's no pageshow in that case, so only a reload unlocks it.
  • The trash row's busy state clears before router.refresh() lands, so the restored row briefly shows an enabled "Restore" again. This is pre-existing.

Gates: typecheck, lint, unit (1205), and build all pass. e2e not run (no Docker).

@datj9
datj9 merged commit 4d4b088 into main Sep 24, 2026
8 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant