Repository navigation
fix: give the console navigation below 1024px, plus five console defects (#1367) - #1379
Conversation
…cts (#1367) The console sidebar was `hidden lg:flex` with nothing replacing it, so on a phone, a portrait tablet or a small laptop all thirteen nav links were display:none and there was no route to Billing, Members, Analytics, Logs, API keys or Settings from any page. Measured at 375px on /console/billing, the entire tabbable set was five elements: Documentation, two locale buttons, sign out, and one page action. The same sidebar element is now the drawer, rather than a second copy of the nav rendered for small screens. One element means one set of links, one set of element ids, and no way for the two to drift apart. Closed, it keeps the `hidden` class, so those links stay out of the tab order instead of sitting off-screen and focusable. Open, it is a fixed overlay above a scrim, and the `lg:` overrides return it to its static grid column at desktop widths whatever the open flag says, so resizing a narrow window up never strands it. Escape closes it, the scrim closes it, and following any link inside it closes it, keyed on an actual anchor rather than on the route changing so that tapping the entry for the page you are already on still dismisses. Only the interactive frame is a client component. ConsoleShell stays a Server Component because it renders LocaleSwitcher, which is an async Server Component and cannot be imported into a client module. Also in this pass: Horizontal overflow at 375px on /console/api-keys and /console/catalog. The DataTable wrapper was `overflow-hidden`, which clipped every column past the fold away with no way to reach it, and an over-wide table inside a non-positioned overflow:auto box still propagates its layout overflow to the viewport in Chromium. Measured: the wrapper was already the right width (343px, scrollWidth 802) and body scrollWidth was 375, yet the page still dragged 428px sideways. `relative overflow-x-auto` fixes both halves. A branded 404. The repository had no app/not-found.tsx at all, so Next.js served its own unstyled page for every miss, including the deliberate ones: notFound() is the access control on /console/providers, /console/feature-gates and /console/marketplace (the #947/#948/#949 family). Built on AuthShell, not ConsoleShell, so it cannot hand a non-admin the workspace name, the signed-in identity and the full rail that the 404 status was withholding. Its complete tabbable set is one link, to the console front door. GRAFANA_BASE_URL no longer appears in customer copy. The disabled observability tile printed the server-side environment variable name into the page for every account that opened Analytics. It now says the dashboard is unavailable, which is the part a reader can act on. The dead grafanaConfigured local is removed. Ledger ordering, fixed at the source. ListEntriesWithCursor ordered by `id`, which is gen_random_uuid, so a customer's money history came back shuffled: the billing overview showed five events dated 11, 16, 26, 16, 24 August, and the paginated Ledger tab was shuffled the same way because both read this one query. Now ORDER BY created_at DESC, id DESC, with the keyset resolving the sort key from the cursor id so the existing opaque-cursor API contract is unchanged. An existing test asserted descending id under the name "returns newest first", encoding the bug as the requirement; it now asserts what its name says. A display cap on percentage deltas. derivePeriodDelta is arithmetically right when it returns 3549613%, so this is a product rule rather than a bug fix: at or above 1000% the tile states the bound instead of the figure, because past roughly 10x the exact digits carry no information a reader can act on and cost a tile's whole width. Buy credits contrast. The accent button was white on the accent token, which measured 4.45:1 in light and 2.61:1 in dark, both under AA on the console's primary revenue control. A dedicated --color-accent-solid pair now carries the label in --color-canvas, the same pairing the primary variant already uses, which lets one background token flip direction cleanly between themes. Re-measured in the browser: 5.65:1 light, 7.45:1 dark. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WyEwUxZCArdn1ZUDkTvuQ1
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Review limit reachedNext included review available in 4 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (16)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Visual proofIssue #1367 at 375px. 1: origin/main, no navigation at all, whole tabbable set is Documentation, EN, বাংলা, Sign out, Revoke. 2: this branch, same page, nav toggle present. 3: drawer open, all thirteen sections plus workspace switcher and sign out reachable. 4: dark theme, Buy credits now 7.45:1 (was 2.61:1) and the ledger reads 26, 24, 16, 11 August. 5: branded 404, whose entire tabbable set is one link to the console front door, so a deliberate notFound() gate no longer leaks the rail it was hiding. |
…keyset Drawer accessibility. The open drawer left the page behind the scrim fully focusable, so a keyboard user tabbing past the last nav entry walked into content they could not see and a screen reader read all of it. `<main>` is now `inert` while the drawer is open, applied to main only and not to the header so the toggle stays reachable as the close control. Focus moves into the panel on open and returns to the toggle on close. The background no longer scrolls behind the drawer. Crossing up past lg now closes the drawer. Without that, opening at phone width and resizing to desktop left a stale open state holding the body scroll lock and the inert attribute on a layout where the rail is static again. A resize listener rather than matchMedia: same answer, one less browser API to stub, and it is bound only while the drawer is open. Ledger keyset. The new ORDER BY carries a tie-breaker the existing index does not, so a group of entries sharing a created_at had to be read whole and sorted before the LIMIT could apply. Adds a covering index on (account_id, created_at desc, id desc). CONCURRENTLY, unlike the plain build in 20260801_02, because credit_ledger_entries is written by every billable request and an ACCESS EXCLUSIVE lock for the length of the build would stall the money path; apply-migrations.sh runs each file without a wrapping transaction, so that is available here. The invalid-index-on-failure trap that CONCURRENTLY carries, and its repair, are written into the migration rather than left to be discovered. The ordering tests claimed tie safety and had no ties in the fixture. Adds a four-entry group sharing one timestamp and walks it two at a time so both page boundaries fall inside the group, asserting the order is total and no row is skipped or repeated. Verified red by dropping the tie-breaker from both the ORDER BY and the row comparison. Renames the nav-link test to say what it asserts. jsdom does not navigate on an anchor click, so what is proven is that activating a link dismisses the drawer, not that it waits for a navigation event. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WyEwUxZCArdn1ZUDkTvuQ1
Adversarial review, round 1Streams run:
Six findings addressed in b142dc5. Two rebutted with evidence. Addressed1. No focus trap, no Fixed with Focus now moves into the panel on open and returns to the toggle on close. Verified in Chromium, 24 Tab presses from the open drawer, checking at every stop whether focus landed inside 2. No background scroll lock (Medium). Correct, fixed, and the previous value is restored on cleanup rather than assumed to be empty. 3. Index does not cover the tie-breaker (High). Correct. One deliberate difference from the suggestion: Applied twice against the migrated throwaway Postgres: 5. Ordering tests claimed tie safety with no ties in the fixture (Medium). Correct, and a fair hit: the comment asserted something the fixture could not exercise. Added Verified red by dropping the tie-breaker from both the 6. Test name overstates what it proves (Low). Correct. Renamed to "closes when a nav link is activated", with a comment saying that jsdom does not navigate on an anchor click and why the dismissal still matters. 8. Stale open state after resizing up to desktop (Low). Correct, and worse than described once fixes 1 and 2 landed, since a stale Verified in Chromium: Rebutted4. Every console route inherits the root layout's The underlying mechanic is real, though, and the finding is a good one to have on record: if someone later adds 7. Hardcoded English should go through next-intl (Medium). Declining, because this describes the repository's current state rather than something this change introduced. The new strings follow that convention exactly, and the two that do belong to an already-translated namespace did go through it: Verdict after the round: the six actionable findings are fixed and re-verified, the two remaining are answered with evidence. Full ledger package green against the migrated throwaway Postgres (14 tests), 912 of 912 frontend tests pass, The one frontend test file that fails locally, |
Visual proofRe-captured at 375px against the post-review code (b142dc5), so the proof matches what would merge. Same drawer, now with focus moved into the panel on open, main inert behind the scrim, and background scroll locked. Measured in the same session: 24 Tab presses from the open drawer never reach page content behind the scrim, Escape returns focus to the toggle, and resizing to 1280 drops the open state along with the lock and the inert. |
## What this is A batch of nine small, independently verified console defects. Each is a few lines on the same surface, so separate pull requests would have cost more in review than in code. They are listed below in the order they were ranked by value per unit of risk. Fixes #500, #516, #490, #1270, #527, #557, #1258, #1290, #1291. #501 is no longer in this list. It was closed on `main` by #1379 while this branch was in review, with the same `overflow-hidden` to `overflow-x-auto` change plus a `relative` that this branch did not have. On rebase that line resolves to `main`'s version, comment and all: the `relative` is load-bearing, because Chromium propagates an over-wide table's layout overflow past a non-positioned `overflow: auto` ancestor to the viewport, so without it the page still drags sideways. What survives from this branch on that file is the pending state and its tests, not the overflow class. **#847 is NOT fixed by this.** Only the cancelled-picker half of it is. The real upload path that issue is actually about is untouched, so please do not close it on this pull request. ## Web console **#501, every table clipped instead of scrolling.** Landed on `main` first, in #1379, so this branch carries `main`'s line rather than its own. The unit test this branch added for it stays, because it pins the property against a silent revert (it fails if the class goes back to `overflow-hidden`), and it also pins that the wrapper is not focusable, which is a decision this branch made and #1385 tracks. **#500, DataTable had no pending state.** Every console table rendered "No records yet." while its data was still in flight, which reads as an empty account rather than a pending one. `DataTable` now takes an optional `loading` prop, renders a pending row while it is set, and marks the body `aria-busy`. Callers are unchanged; the prop defaults to false. **#516, an empty chart rendered three bare axis frames.** Recharts happily draws axes over an empty series, so a new account saw three skeletal frames and no explanation. `ChartCard` now takes the rows it is wrapping and renders an honest empty state, at the chart's own height so nothing jumps, instead of rendering the chart at all. All four call sites pass their rows. **#490 and #1270, the charts inverted in dark mode.** The three analytics charts hardcoded hex literals for the panel background and for every series, which froze them at their light values while the rest of the console follows `prefers-color-scheme`. They now draw from a new `components/analytics/chart-theme.ts`, which is nothing but `var(--color-*)` references to variables `app/globals.css` already defines. That covers more than the literals named in the issue: the grid stroke, the axis strokes and tick fills, the tooltip surface and the legend text were all still recharts defaults tuned for a light page, so all of them are themed too. A guard test reads the three sources and fails if a quoted hex literal comes back, because a colour passed to recharts as a prop is not something a stylesheet can correct later. **#527, the dead nav shell is deleted.** Zero importers, verified by grep before and after, and a near-duplicate of the real app shell, which makes it exactly the file a future change edits by mistake. ## Chat front end (vendor/open-webui) This half is frontend only, so it travels through the image build's frontend stage rather than needing an entry under `deploy/docker/owui-patches/`. It does mean this pull request touches two build systems. **#557, a console warning on every anonymous load.** The layout warned "No token found in localStorage" for every signed-out visitor. Not having a token is the normal anonymous state, not a fault. The `user-join` emit stays guarded exactly as it was; only the warning is gone. **#1258, dead Tools branches.** The Integrations tab was removed from the tab list, which left three unreachable `'tools'` branches in the settings modal (the visibility filter, the tab button, the content pane) plus the two imports they were the only user of. Deleted. **#847 partial, a cancelled file picker reported an error.** A cancelled picker fires `change` with an empty `FileList`, which is indistinguishable from selecting nothing, and the handler reported that as "File not found." to every user who backed out of the dialog. The toast is dropped on an empty selection. Fixed in the chat composer, which the issue names, and also in the channel composer, which carries the identical handler one directory over. The two workspace knowledge surfaces and the admin add-user modal carry the same copy-pasted pattern in flows outside this batch's scope and are left for a follow-up. ## Docs **#1290 and #1291, the process doc taught a wrong number.** The doc said 200 open issues; the real figure is 343 as of 2026-08-29, from `gh issue list --state open --limit 1000 --json number --jq 'length'`, cross-checked against a paginated REST enumeration. The surrounding claims were stale the same way, so they were re-derived rather than left: 16 open PRs is now 5, 75 labels is now 76, and the label-coverage sentence (98 of 200 unlabelled, 32 legacy-only, roughly two-thirds of the backlog) is now 126 of 343 unlabelled and 45 legacy-only, which is roughly half. The paragraph now carries the two commands that re-derive both figures, because this is a number that moves by dozens in a day and the next reader should re-run it rather than trust the print. ## Verification Web console, built and run against a private image tag so no shared tag was rebuilt under another agent, with a fresh `docker build` because the service mounts no volume and copies source at image build time: - `npx tsc --noEmit`: clean. - `npx vitest run`: 921 passed across 82 files, including the three new files. - `npm run build`: clean. Chat front end: `scripts/test-owui-hive-frontend.sh`, 233 passed across 17 files, 14 of 14 Hive components compiled. That script's compile pass is scoped to `src/lib/hive`, so it does NOT compile the four upstream components this pull request edits; it only reads them as text fixtures. Those four are compiled by the image build's `npm run build` instead, which is the gate that actually covers them. The channel composer was not in the script's fixture copy list at all, so it is added there, otherwise the new guard that reads it would have failed on a missing file rather than on its contents. Every new test was mutation-checked, each mutation applied to a copy and mounted over the image rather than committed: | Mutation | Result | |---|---| | `overflow-x-auto` back to `overflow-hidden` | 1 failed, 3 passed | | loading branch made unreachable | 2 failed, 2 passed | | empty-rows branch made unreachable | 2 failed, 1 passed | | one hex literal restored in the usage chart | 1 failed, 9 passed | | all four chat front end strings reintroduced | 4 failed, 229 passed | One pre-existing flake, unrelated to this batch and reported here rather than left unmentioned: the analytics billing page wiring test times out at the default 5000 ms when the full suite runs under load in a container, and the timeout then cascades into a second failure in the same file because the first test's DOM is never cleaned up. It passes on `origin/main`'s version of these files in the same container, it passes on this branch when run alone, and the whole suite passes at `--testTimeout=30000`. Worth its own issue; not introduced here. ## Buglog entry To be appended to `.wolf/buglog.jsonl` on `main` in a separate buglog-only pull request once this merges, per `.claude/rules/openwolf.md`. ```json {"date":"2026-08-29","tags":["console","web","ui","dark-mode"],"title":"Analytics charts inverted in dark mode because their colours were hex literals","error_message":"Every analytics chart rendered a light panel with light-mode series colours against the dark console palette","root_cause":"The three chart components passed hex literals to recharts for the panel background and every series, and left the grid, axes, tooltip and legend at recharts light-tuned defaults. A colour handed to recharts as a prop is not reachable from a stylesheet, so the prefers-color-scheme override could not correct any of it.","fix":"Added a chart-theme module holding CSS variable references for the panel, grid, axes, tooltip, legend and every series, and pointed all three charts at it. A guard test reads the sources and fails on a quoted hex literal."} {"date":"2026-08-29","tags":["console","web","ui","empty-state"],"title":"Console tables and charts reported an empty account while they were still loading","error_message":"Tables flashed 'No records yet.' during every fetch and empty charts rendered three bare axis frames with no explanation","root_cause":"DataTable had no pending state at all, so the empty branch was the only thing it could render before data arrived. ChartCard passed an empty series straight to recharts, which draws its axes regardless.","fix":"DataTable takes a loading prop and renders a pending row with aria-busy. ChartCard takes the rows it wraps and renders an empty state at the chart own height instead of rendering the chart."} {"date":"2026-08-29","tags":["chat","owui","frontend","noise"],"title":"Chat front end reported two non-errors to the user and the console","error_message":"No token found in localStorage warned on every signed-out page load, and File not found. toasted whenever the file picker was cancelled","root_cause":"The socket connect handler treated an absent token as worth warning about when it is the normal anonymous state, and both composers treated a change event with an empty FileList as a missing file when it is also what pressing cancel produces.","fix":"Dropped the warning and kept the emit guard. Dropped the toast on an empty selection in both composers. A guard test pins all of it, plus the removal of the dead tools branches."} ```
## Summary This is the batched buglog follow-up for the pull requests merged to `main` on 2026-08-29. Its diff is `.wolf/buglog.jsonl` and nothing else. Per `.claude/rules/openwolf.md`, every fixed bug, error, failed test or failed build must be logged, but the line may never be appended on a fix branch. `merge=union` in `.gitattributes` resolves concurrent appends locally and is ignored by GitHub's server side merge, so two branches that both appended land in hard conflict there. An unmergeable pull request gets no `refs/pull/N/merge`, no `pull_request` run and therefore zero checks, and the required status gate then blocks the merge for a reason the page never states (issue #873). Each fix accordingly carried its entry in its own pull request body, and this pull request copies them onto `main` in one batch, which the protocol explicitly prefers over one pull request per entry. ## Scope examined Fifty nine pull requests merged to `main` on 2026-08-29. Forty eight of them carried at least one entry, for eighty two entries in total. Thirty two of those were already on `main` and are skipped, leaving fifty appended here from thirty four pull requests. The largest block of skips comes from #1342, the equivalent batch for the 2026-08-28 merges, which merged earlier the same day and already landed thirty six entries covering #1257, #1268, #1276, #1277, #1287, #1292, #1293, #1294, #1296, #1301, #1303, #1305, #1313, #1335 and #1337. ## What landed Fifty entries appended, one JSON object per line, append only. The 232 pre-existing lines are byte identical to `origin/main` (verified by hashing the first 232 lines of the result against the base file). Every line in the resulting file parses as JSON and carries `error_message`, `root_cause`, `fix` and `tags`. | Source | Entries | |---|---| | #1083 | 2 | | #1277 | 1 | | #1278 | 1 | | #1298 | 1 | | #1334 | 1 | | #1336 | 3 | | #1343 | 1 | | #1346 | 1 | | #1351 | 1 | | #1365 | 2 | | #1368 | 1 | | #1369 | 1 | | #1371 | 3 | | #1375 | 3 | | #1376 | 1 | | #1378 | 1 | | #1379 | 2 | | #1388 | 5 | | #1389 | 3 | | #1390 | 2 | | #1393 | 1 | | #1394 | 1 | | #1410 | 1 | | #1417 | 1 | | #1421 | 1 | | #1423 | 1 | | #1424 | 1 | | #1426 | 1 | | #1429 | 1 | | #1431 | 1 | | #1433 | 1 | | #1434 | 1 | | #1436 | 1 | | #1439 | 1 | Entries are copied verbatim from their source pull request bodies. Nothing was rewritten, no field was invented, and no field was added. No JSON needed repair: all eighty two extracted entries parsed on the first attempt and all four required fields were present on every one. ## Merged pull requests that carried no entry Eleven of the fifty nine. Recorded here because the gap is itself the useful signal. | Pull request | Title | Assessment | |---|---|---| | #1013 | chore(deps): bump the go-minor-patch group across 1 directory with 4 updates | Dependabot bump, no defect fixed, no entry expected | | #1015 | chore(deps): bump the go-minor-patch group across 1 directory with 6 updates | Dependabot bump, no entry expected | | #1016 | chore(deps): bump golang from 1.26-alpine to 1.27-alpine in /deploy/docker | Dependabot bump, no entry expected | | #1218 | chore(deps): bump postcss from 8.5.19 to 8.5.26 in /apps/desktop | Dependabot bump, no entry expected | | #1219 | chore(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in /apps/control-plane | Dependabot bump, no entry expected | | #1342 | chore: batch buglog entries for the 2026-08-28 merges | The previous batch pull request itself, correctly carries no entry of its own | | #1364 | chore: remove four dead skills and record the patterns that cost time | Protocol gap. The body records patterns that cost time, which is the shape of a buglog entry, but none was written as one | | #1383 | test: retire stale expected-failure markers, restore the ones that are true (#1381, #1382, #1324) | Protocol gap. Stale `it.fails` markers reading as red is a real defect that was fixed here and should have carried an entry | | #1384 | docs: correct D-047, hive-auto reverted to variable pricing (D-059) | Decision ledger correction, arguably a documentation defect, no entry written | | #1387 | chore(deps): bump next from 15.5.23 to 16.3.3 in /apps/agent-console | Dependabot bump, no entry expected | | #1398 | docs: rescue the 2026-08-25 parity captures and add the 2026-08-29 QA matrix evidence | Documentation and evidence rescue, no entry written | Six of the eleven are Dependabot bumps and one is the previous batch, so the genuine protocol gaps are #1364, #1383, #1384 and #1398. Of those, #1383 is the one worth a follow-up: it fixed a real defect class (a stale expected-failure marker reads as a red "Expect test to fail" and gets dismissed as pre-existing) and left no record. ## Entries skipped as already present Thirty two. Thirty of them matched an entry already on `main` on `error_message`, `id` or `fix`. Two more from #1278 are semantic duplicates that an exact match would have missed, and were skipped after reading the landed entries they duplicate: - #1278's `streaming content_block_start omits text field` entry is covered by the consolidated `bug-2026-08-28-anthropic-sdk-wire-conformance` entry landed from #1296, whose root cause names the same `omitempty` on `StreamContentBlock.Text`. - #1278's `GET /v1/models leaked an upstream provider name` entry is covered by `BUG-1284`, landed from #1300, which names the same `public.model_aliases.summary` publication path. #1278's third entry, on `top_k` forwarding producing a 400, is not covered anywhere on `main` and is appended here. #1342 recorded #1278 as fully "merged into #1296", which was accurate for two of its three entries. ## Note on entry quality One appended entry is thin: #1277's parity re-score record carries `error_message` of `n/a` and a root cause of "console had no privacy/data-policy surface at all". It is a parity gap record rather than a defect record. It is included exactly as written rather than embellished, per the protocol's preference for the author's own words. ## Test plan - [x] Branch cut fresh from `origin/main`, diff is `.wolf/buglog.jsonl` and nothing else - [x] First 232 lines byte identical to the base file (md5 match) - [x] All 282 resulting lines parse as JSON and carry `error_message`, `root_cause`, `fix` and `tags` - [x] No `.wolf/` telemetry (`anatomy.md`, `memory.md`, `token-ledger.json`, `hooks/_session.json`, `buglog.json`) in the commit - [ ] The six required checks report green via the inert path allowlist in `.github/workflows/ci.yml` --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>






Fixes #1367.
1. The console had no navigation below 1024px
console-shell.tsxrendered the sidebar ashidden lg:flexwith nothing replacing it, so on a phone, a portrait tablet or a small laptop all thirteen nav links weredisplay: none. Measured at 375px on/console/api-keys, the entire tabbable set was five elements:Not one of them is a route. Billing, Members, Analytics, Logs, API keys and Settings were unreachable from every page.
What this does. The same
<aside>is now the drawer, rather than a second copy of the nav rendered for small screens. One element means one set of links, one set of element ids, and no way for the two to drift apart (a duplicated rail would also have duplicatedworkspace-switcher-select). Closed, it keeps thehiddenclass, so those thirteen links stay out of the tab order instead of sitting off-screen and focusable. Open, it is a fixed overlay above a scrim, and thelg:overrides return it to its static grid column at desktop widths whatever the open flag says, so resizing a narrow window up never strands the drawer over the page.It dismisses on Escape, on the scrim, and on following any link inside it. That last one is keyed on an actual anchor rather than on the route changing, because tapping the entry for the page you are already on changes no route and still has to close.
Only the interactive frame is a client component.
ConsoleShellstays a Server Component: it rendersLocaleSwitcher, which is an async Server Component readingnext-intl/server, and that cannot be imported into a client module. The sidebar and header are passed in as slots.Touch targets: nav rows get
min-h-11 lg:min-h-0, so they are 44px while the rail is a touch drawer and unchanged at desktop widths.New strings
Shell.openNav/Shell.closeNavin bothen.jsonandbn.json.2. Horizontal overflow at 375px on /console/api-keys and /console/catalog
Two defects stacked in
DataTable's wrapper, and both words of the fix are load-bearing.overflow-hiddenclipped every column past the fold with no way to reach it: on a phone the API keys table simply stopped after Name. That is nowoverflow-x-auto, so the table scrolls inside its own container.relativeis the other half. The table was already inside a scroller that was the right width (wrapperoffsetWidth343,scrollWidth802,body.scrollWidth375) and the document still dragged 428px sideways, because Chromium propagates an over-wide<table>'s layout overflow past a non-positionedoverflow:autoancestor to the viewport. Isolated experimentally on the running page before writing the fix:A/B measured in one browser session by restoring origin/main's wrapper at runtime:
/console/api-keys/console/catalogThe header and main padding also drop from
px-6topx-4 sm:px-6, which returns 16px of usable width at 375px and is unchanged fromsmup.3. The 404 item, reframed
/console/providersis not broken.app/console/providers/page.tsx:33callsnotFound()deliberately, as the access control for the #947/#948/#949 family. The real defect was that the repository had noapp/not-found.tsxat all, so a deliberate gate rendered Next.js's stock unstyled 404.How this version avoids disclosing the authenticated surface. A branded 404 built on
ConsoleShellwould have handed a non-admin the workspace name, the signed-in identity, and the full thirteen-entry rail including the operator-only Admin group, in the body of the very response whose 404 status exists to withhold exactly that. So the page is built onAuthShellinstead, which is the chrome the signed-out pages already use: same brand, same type, same colours, and no navigation at all. The single way back is/console, the console's front door, which every viewer can already reach and which discloses nothing about what lies past it. There is no "did you mean" list, no route suggestion, and no echo of the path that missed.Verified: the rendered page's complete tabbable set is
["Back to the console"], and a unit test asserts both that the onlyhrefon the page is/consoleand that the body text never matches/Providers|Feature gates|Marketplace|Sign out|Workspace/i.Structurally this holds because the root boundary renders inside the root layout only;
app/console/layout.tsxis not applied to it. An unauthenticated visitor never reaches the 404 anyway, because that layout redirects to sign-in before the page can throw.4. GRAFANA_BASE_URL leaked to the customer
The string at
observability-tiles.tsx:84printed a server-side environment variable name into the page for every account that opened Analytics, as an instruction nobody reading that page can act on. It now reads "Not available on this deployment." The deadgrafanaConfiguredlocal at:45is removed.5. Billing ledger was unsorted, fixed at the source
Root cause is not in the view.
ListEntriesWithCursor(apps/control-plane/internal/ledger/repository.go) ordered byid, andidisgen_random_uuid()(supabase/migrations/20260330_01_credits_ledger.sql), a v4 UUID carrying no time order at all. Both the overview's "last five ledger events" and the paginated Ledger tab read that one query, so patching only the overview would have left the tab shuffled.Now
ORDER BY created_at DESC, id DESC, withidbreaking ties so the ordering is total and the keyset cannot skip or repeat a row sharing a timestamp. The keyset resolves the sort key from the cursor id:so the cursor stays the entry id and the API contract in
http.go, plus every cursor already in a customer's hands, keeps working.idx_credit_ledger_entries_account_created_atalready covers the leading columns. An unknown cursor id yields NULL and therefore an empty page, which is the right answer for a cursor that names no row, and is covered by a test.One existing assertion had to change:
TestListEntriesWithCursorFilters_Live/default_limit_returns_newest_firstcompared descendingidunder that name, which encoded the bug as the requirement. It now asserts non-increasingcreated_at, which is what its name always claimed.6. Percentage delta cap: a new product rule, with a stated threshold
derivePeriodDeltais arithmetically correct when it returns 3549613%, because the prior period really was a small nonzero number, and the code already handlesfromZeroandpercent === nullseparately. So this is a product decision, not a bug fix.Threshold: 1000%, roughly 10x. Reasoning: a percentage informs a reader only while the ratio still fits in their head. Past that point "3,549,613%" and "998,004%" carry one and the same actionable fact, that the prior period was next to nothing, and the extra six digits buy nothing while costing a tile's entire width. At and above the threshold the tile prints
over 1,000%and states the bound instead of the figure. In practice this only ever fires on increases, since a decrease cannot pass -100%.7. Buy credits contrast
The
accentbutton variant wastext-whiteon--color-accent: 4.45:1 in light and 2.61:1 in dark, both under AA on the console's primary revenue control.A dedicated
--color-accent-solid/--color-accent-solid-hoverpair now backs it, with the label in--color-canvas, the same pairing theprimaryvariant already uses. That is what lets one background token flip direction cleanly: near-white on a dark sienna in light mode, near-black on a light sienna in dark mode.--color-accentitself is untouched, since it is a foreground on canvas (links, focus rings, active icons) solving a different problem.Re-measured live in Chromium, both themes: 5.65:1 light, 7.45:1 dark.
Measurement note for whoever checks this:
getComputedStylereturnedlab(43.1662 40.7486 40.2976)andlab(66.4258 39.8986 37.0067)for these backgrounds. A parser matching onlyrgb()skips those silently and under-reports. The capture round-trips every colour through a 1x1 canvas so the browser does the conversion.Tests
Nine new frontend tests in
apps/web-console/tests/unit/console-mobile-nav.test.tsxand three new Go live tests inapps/control-plane/internal/ledger/repository_order_live_test.go.Every one of them was verified to go red. Each mutation was applied to the source, the suite re-run, and the failure confirmed to be the intended test and only the intended test:
event.key === "Escape"to"F13".closest("a")to.closest("form")hidden lg:flexwhen open/console/billinglink added to the 404if (false)ORDER BY created_at DESC, id DESCback toORDER BY id DESCThe Go tests were run against a real migrated Postgres, not a mock: a throwaway
pgvector/pgvector:pg17container bootstrapped withscripts/ci-throwaway-db.sh(112 of 112 migrations executed), databasehivetest. Full ledger package green afterwards.The ordering fixture uses fixed UUIDs chosen so that id order is the exact inverse of time order, so the old
ORDER BY id DESCreturns the set oldest-first on every run rather than being accidentally chronological one time in 24.Frontend: 911 of 911 tests pass. The one failing file,
tests/unit/ci-web-e2e-secret-free.test.ts, fails only because.github/workflows/ci.ymlis not inside the web-console Docker image; it is unrelated to this change and passes in CI on a full checkout.npm run build,npm run lint:proof-tokensandnpm run lint:go-db-test-wiringall pass.Visual proof
Posted as an inline comment on this pull request. The text log is committed at
docs/proof/console-mobile-nav-1367-2026-08-29/capture-log.mdand carries the substrate note, every measurement above, and the cleanup record. No credential appears anywhere in the capture: nothing in this flow puts a token in a query string, and no sign-in happens at all.Buglog entry
{"id":"bug-1367-console-mobile-nav","date":"2026-08-29","title":"Console had no navigation at all below the lg breakpoint, and three of its tables scrolled the whole document sideways at 375px","error_message":"At 375px on /console/api-keys the entire tabbable set was Documentation, Switch to EN, Switch to বাংলা, Sign out, Revoke: no route to any console section from any page. Separately, document.documentElement.scrollWidth was 803 on /console/api-keys and 818 on /console/catalog against a 375px viewport, and window.scrollX reached 428.","root_cause":"console-shell.tsx rendered the sidebar as `hidden lg:flex` with no small-screen replacement, so all thirteen nav links were display:none below 1024px. The overflow was two stacked faults in DataTable's wrapper: `overflow-hidden` clipped every column past the fold instead of scrolling it, and because the wrapper was not a containing block, Chromium propagated the over-wide table's layout overflow past that overflow:auto ancestor to the viewport, so the page scrolled sideways even though the scroller itself was the correct width.","fix":"Made the same aside element the drawer (fixed overlay plus scrim below lg, static grid column at lg and up) driven by a small client ConsoleFrame, keeping ConsoleShell a Server Component because it renders the async LocaleSwitcher. Closed state keeps `hidden` so the links stay out of the tab order. Changed DataTable's wrapper to `relative overflow-x-auto`.","tags":["console","responsive","accessibility","navigation","css-overflow","chromium","issue-1367"]}{"id":"bug-1367-ledger-order","date":"2026-08-29","title":"Credit ledger returned a customer's money history in random order","error_message":"The billing overview's 'last five ledger events' rendered as 11, 16, 26, 16, 24 August, and the paginated Ledger tab was shuffled the same way.","root_cause":"ListEntriesWithCursor ordered by `id` and paginated on `id < cursor`. credit_ledger_entries.id is `gen_random_uuid()`, a v4 UUID, so it encodes no time order whatsoever. Both the overview and the Ledger tab read that one query, so a view-level sort would have fixed only half of it. An existing live test asserted descending id under the name 'default limit returns newest first', which encoded the bug as the requirement and kept it green.","fix":"ORDER BY created_at DESC, id DESC, with the keyset expressed as (created_at, id) < (SELECT created_at, id FROM credit_ledger_entries WHERE id = $cursor) so the opaque cursor stays the entry id and the API contract is unchanged. Corrected the existing assertion to compare created_at, and added three live Postgres tests using fixed UUIDs whose id order is the exact inverse of time order.","tags":["billing","ledger","postgres","pagination","keyset","control-plane","camouflaged-test"]}🤖 Generated with Claude Code
https://claude.ai/code/session_01WyEwUxZCArdn1ZUDkTvuQ1