fix(dashboard): tile strip back to CSS Grid for true symmetric width

Marc's v1.4.4 feedback: the tiles look better when they're all
exactly the same size and the strip begins and ends at the same
x-coordinate as the charts — the v1.4.4 `flex-1 basis-0` distribution
was close but not quite symmetric, and at 6 tiles the right edge
of the strip didn't line up with the charts below.

Switched to the v1.3-era pattern but with the modern `auto-fit +
minmax(9rem, 1fr)` form so it doesn't need explicit breakpoints:

  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 0.75rem;

CSS Grid puts every tile in an identical 1fr track. Width is
literally identical, gaps are symmetric, and the strip is bounded
by the same parent box as the charts so left/right edges align by
construction. When the row no longer fits a 9 rem floor the grid
wraps to a new row — Marc explicitly tested both and prefers the
wrap over the v1.4.x horizontal-scroll for the symmetry it
preserves.

Tests + typecheck green (671/671).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 00:05:35 +02:00
1 parent cbded7c39e
commit e8f4820018
5 files changed
+69 -23

No files matched your search

+25
View File
@@ -1,5 +1,30 @@
# Changelog
## [1.4.5] — 2026-05-09
### Fixed — Settings · KI
- **"Verbindung testen" no longer crashes the page when the key is
bad.** The endpoint mapped every upstream error to HTTP 502, which
Cloudflare intercepts and replaces with its own HTML error page.
`await res.json()` in the browser then crashed with `Unexpected
token '<', "<!DOCTYPE "`. 401/403 from the provider now map to 422,
429 to 429, and only genuine 5xx upstream errors keep the 502 —
Cloudflare passes 4xx through untouched, so the React Query
mutation reads the JSON body and surfaces a readable message.
### Fixed — Dashboard
- **Dashboard tiles use a CSS Grid layout again** — the v1.3-era
pattern that gave each tile an identical width with symmetric gaps
and an edge-to-edge fit with the charts below. v1.4.4 distributed
width via `flex-1 basis-0`; CSS Grid is more honest about the
intent and survives viewport changes more gracefully.
`grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr))` keeps
every track equal-width with a 9 rem floor; the row wraps when
the floor no longer fits instead of triggering a horizontal
scroll.
## [1.4.4] — 2026-05-08
### Fixed — Dashboard
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.4",
"version": "1.4.5",
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
+9 -2
View File
@@ -94,7 +94,11 @@ describe("POST /api/ai/test — provider error leak guard", () => {
const response = await POST(emptyRequest() as never);
const body = (await response.json()) as ApiErrorEnvelope;
expect(response.status).toBe(502);
// v1.4.5: credential failures map to 422 (not 502) so Cloudflare
// doesn't replace our JSON body with its HTML error page — that
// rewrite was the root cause of the "Unexpected token '<'…" client
// crash Marc hit when typing a bad OpenAI key.
expect(response.status).toBe(422);
expect(body.error ?? "").not.toMatch(/sk-/);
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
expect(body.error ?? "").not.toMatch(/invalid api key/i);
@@ -106,7 +110,10 @@ describe("POST /api/ai/test — provider error leak guard", () => {
Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(502);
// v1.4.5: rate-limit passes through as 429 (was 502 in v1.4.4) so
// the React Query mutation can read the JSON body instead of
// tripping over Cloudflare's HTML 502.
expect(response.status).toBe(429);
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
"Provider rate-limited the request",
);
+22 -9
View File
@@ -91,15 +91,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
ai_test_provider: provider.type,
},
});
// Map upstream status to a *client-side* status code that won't be
// rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap
// the body for its own HTML error page — `await res.json()` in the
// browser then crashes with `Unexpected token '<', "<!DOCTYPE "`,
// which is exactly how Marc encountered this when typing a bad
// OpenAI key. 4xx codes pass through untouched.
const status = err.httpStatus ?? 0;
const safeMessage =
status === 401 || status === 403
? "Provider rejected the credentials"
: status === 429
? "Provider rate-limited the request"
: status >= 500
? "Provider returned a server error"
: "Provider connection failed";
return apiError(safeMessage, 502);
let outboundStatus: number;
let safeMessage: string;
if (status === 401 || status === 403) {
outboundStatus = 422;
safeMessage = "Provider rejected the credentials";
} else if (status === 429) {
outboundStatus = 429;
safeMessage = "Provider rate-limited the request";
} else if (status >= 500) {
outboundStatus = 502;
safeMessage = "Provider returned a server error";
} else {
outboundStatus = 422;
safeMessage = "Provider connection failed";
}
return apiError(safeMessage, outboundStatus);
}
});
+12 -11
View File
@@ -881,21 +881,22 @@ export default function DashboardPage() {
* touch.
*/}
<div
className="-mx-2 flex snap-x snap-mandatory items-stretch gap-3 overflow-x-auto px-2 pb-2 md:mx-0 md:px-0"
// CSS Grid with `auto-fit + minmax(9rem, 1fr)` is the v1.4.4
// attempt's flex-strip replacement: every tile gets EXACTLY
// the same width (1fr each in the row's track list), the gap
// is symmetric, and the strip starts and ends at the same
// x-coordinates as the charts below because both inherit the
// same parent container. When the row no longer fits a 9rem
// floor, the grid wraps to a new row instead of horizontal-
// scrolling — Marc tested both and prefers the v1.3-era
// wrap behaviour over the one-row scroll for the symmetry
// it preserves.
className="grid auto-rows-fr gap-3 [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] pb-2"
data-slot="dashboard-tile-strip"
data-tile-count={trendCards.length}
>
{trendCards.map((entry) => (
// `flex-1 basis-0` distributes the strip width evenly across
// every tile so 5 tiles or 7 tiles both look symmetric — no
// tile claims more horizontal space because its content is
// wider. `min-w-[9rem]` keeps the cells readable on narrow
// viewports; if the row no longer fits, the parent's
// `overflow-x-auto` lets the user swipe instead of wrapping.
<div
key={entry.id}
className="flex min-w-[9rem] flex-1 basis-0 snap-start"
>
<div key={entry.id} className="flex">
{entry.node}
</div>
))}