Skip to content

Commit c864c98

Browse files
B2JK-Industryclaude
andcommitted
fix(ux): PR-K pass-7 — R-07/R-08/R-09/R-10/R-11 boundaries, z-index, watt meter, i18n, hero filter
Five reliability + visual-regression tickets stacked on top of PR-J. R-07 — page crash error boundary - New `app/miasto/error.tsx` (segment-level boundary). Catches the upgrade-flow exceptions that used to bubble to the browser default error page. Renders a navy hero + Try-again + Back-to-home. - New `app/global-error.tsx` (root-level fallback). Renders its own `<html>` + `<body>` because the layout itself failed; uses inline styles so it works even with a broken design system. The inner `<a href="/">` (intentional plain anchor — `<Link>` is unreliable when the layout did not mount) is suppressed for `@next/next/no-html-link-for-pages` lint with an inline `eslint-disable-next-line`. R-08 — WattDeficitPanel banner crossing the header - Header `z-20` → `z-40` so it always paints on top of scrolling sticky descendants. Banner `z-[30]` → `z-30` (still below header). - Sticky offset corrected: `top-[144px] sm:top-16` → `top-[96px] sm:top-[112px]`. The new offsets add (resource-bar ≈ 40 px) to the actual main-row heights (mobile 56, sm+ 72) — the prior numbers were off-by-N and let the banner sit too low on mobile and too high on sm+. R-09 — yellow WattMeter retint regression - `<rect className="watt-meter">` opt-out class on the inner fill inside `WattMeter` in `city-scene.tsx`. The pko city-scene attribute selector for `[fill="#fde047"]` (R-02 + earlier G-01) retinted the meter to the muted `--sc-window` token — combined with the `saturate(.35)` filter the bar collapsed into invisibility. - `globals.css` selector now `:not(.watt-meter)` so the progress bar fill stays neon yellow (a UI signal, not a decorative window). R-10 — i18n consistency (PL nav, CS body) - `/api/lang` now calls `revalidatePath("/", "layout")` after setting the `xp_lang` cookie. Without it the Next.js 16 RSC cache served the prior render (closed over the old dictionary) on the next navigation, producing nav-in-PL / body-in-CS mixes on `/miasto` and similar surfaces. - `language-switcher.tsx` adds a 250 ms safety net: after `router.refresh()` it checks `document.documentElement.lang` and hard-reloads if the rendered lang did not flip. Belt-and-braces for cases the cache invalidation misses. R-11 — CitySkylineHero light-cream bodies invisible - Class swapped from `city-scene-root` to dedicated `city-skyline-hero-root`. The R-05 daylight surface + R-05 refreshed catalog colours (cream / ivory / light-navy) were being pushed past pure white by the shared `saturate(.35) brightness(1.55)` filter. The hero owns its own palette now — no inherited filter, white background directly, faint navy 0.5 px outline (`stroke: rgba(0, 53, 116, 0.18)`) on rect bodies so light catalog colours read against the sky. Core skin keeps the legacy night canvas via `[data-skin="core"]` scope. Validation: pnpm typecheck clean pnpm test 715/715 vitest pnpm lint 0 errors (4 pre-existing React 19 purity warnings) pnpm test:e2e ux-fixes 14/14 chromium pnpm test:walk (post-pr-k) 0 a11y / 0 console / 0 page, 56 routes pnpm build OK About to push origin/main → Vercel auto-deploy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5cce0e8 commit c864c98

9 files changed

Lines changed: 290 additions & 22 deletions

File tree

app/api/lang/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextRequest } from "next/server";
22
import { cookies } from "next/headers";
3+
import { revalidatePath } from "next/cache";
34
import { LANGS, COOKIE_NAME, type Lang } from "@/lib/i18n";
45

56
export async function POST(req: NextRequest) {
@@ -14,5 +15,11 @@ export async function POST(req: NextRequest) {
1415
maxAge: 60 * 60 * 24 * 365,
1516
sameSite: "lax",
1617
});
18+
// R-10 — bust the RSC cache for the entire layout tree so server
19+
// components re-execute with the new `xp_lang` cookie. Without
20+
// this, Next.js 16 keeps serving the previously-rendered RSC
21+
// payload (which closed over the old dictionary) for /miasto and
22+
// similar surfaces, producing nav-in-PL / body-in-CS mixes.
23+
revalidatePath("/", "layout");
1724
return Response.json({ ok: true, lang });
1825
}

app/global-error.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
5+
/* R-07 — top-level error boundary.
6+
*
7+
* `app/global-error.tsx` only runs when the root layout itself
8+
* throws. It must render its own `<html>` + `<body>` because the
9+
* layout did not. Keep it minimal and dependency-free — the surface
10+
* has to render even if the design system is broken.
11+
*
12+
* Per-segment error.tsx (`app/<segment>/error.tsx`) handles the
13+
* common case; global-error is the last-resort fallback that keeps
14+
* the site from showing the browser default error page.
15+
*/
16+
export default function GlobalError({
17+
error,
18+
reset,
19+
}: {
20+
error: Error & { digest?: string };
21+
reset: () => void;
22+
}) {
23+
useEffect(() => {
24+
if (typeof console !== "undefined") {
25+
console.error("[global-error] root layout error:", error);
26+
}
27+
}, [error]);
28+
29+
return (
30+
<html lang="pl">
31+
<body
32+
style={{
33+
margin: 0,
34+
padding: "48px 24px",
35+
fontFamily:
36+
"Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
37+
background: "#ffffff",
38+
color: "#000000",
39+
minHeight: "100vh",
40+
}}
41+
>
42+
<main
43+
style={{
44+
maxWidth: "560px",
45+
margin: "0 auto",
46+
display: "flex",
47+
flexDirection: "column",
48+
gap: "16px",
49+
}}
50+
>
51+
<div style={{ fontSize: "48px", textAlign: "center" }} aria-hidden>
52+
⚠️
53+
</div>
54+
<h1
55+
style={{
56+
color: "#003574",
57+
fontSize: "32px",
58+
lineHeight: "40px",
59+
fontWeight: 700,
60+
textAlign: "center",
61+
margin: 0,
62+
}}
63+
>
64+
Coś poszło nie tak
65+
</h1>
66+
<p
67+
style={{
68+
color: "#636363",
69+
fontSize: "16px",
70+
lineHeight: "24px",
71+
textAlign: "center",
72+
margin: 0,
73+
}}
74+
>
75+
Aplikacja napotkała nieoczekiwany błąd. Spróbuj ponownie, lub odśwież
76+
stronę.
77+
</p>
78+
{error.digest && (
79+
<p
80+
style={{
81+
color: "#b7b7b7",
82+
fontSize: "13px",
83+
fontFamily: "monospace",
84+
textAlign: "center",
85+
margin: 0,
86+
}}
87+
>
88+
ref: {error.digest}
89+
</p>
90+
)}
91+
<div
92+
style={{
93+
display: "flex",
94+
gap: "12px",
95+
justifyContent: "center",
96+
marginTop: "8px",
97+
}}
98+
>
99+
<button
100+
type="button"
101+
onClick={reset}
102+
style={{
103+
padding: "12px 20px",
104+
borderRadius: "10px",
105+
background: "#003574",
106+
color: "#ffffff",
107+
border: "1px solid #003574",
108+
fontWeight: 600,
109+
cursor: "pointer",
110+
}}
111+
>
112+
Spróbuj ponownie
113+
</button>
114+
{/* Plain `<a>` (not next/link) is intentional — global-error
115+
runs even when the root layout failed to mount, so
116+
`<Link>` and the next router are not reliable here. The
117+
next/link lint rule is suppressed for this file only. */}
118+
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
119+
<a
120+
href="/"
121+
style={{
122+
padding: "12px 20px",
123+
borderRadius: "10px",
124+
background: "#ffffff",
125+
color: "#003574",
126+
border: "1px solid #003574",
127+
fontWeight: 600,
128+
textDecoration: "none",
129+
display: "inline-flex",
130+
alignItems: "center",
131+
}}
132+
>
133+
Strona główna
134+
</a>
135+
</div>
136+
</main>
137+
</body>
138+
</html>
139+
);
140+
}

app/globals.css

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,9 +249,13 @@ body {
249249
}
250250

251251
/* Neon-yellow window glow → muted warm yellow per spec (window bucket).
252-
* Limit to FILL only — strokes already retinted above. */
253-
:where([data-skin="pko"]) .city-scene-root [fill="#fde047"],
254-
:where([data-skin="pko"]) .city-scene-root [fill="#facc15"] {
252+
* Limit to FILL only — strokes already retinted above.
253+
* R-09 — `:not(.watt-meter)` keeps the best-score progress bar fill
254+
* visibly yellow. The previous mass override retinted it to the
255+
* muted window token, which on top of the `saturate(.35)` filter
256+
* collapsed the bar into invisibility against the dark slot bg. */
257+
:where([data-skin="pko"]) .city-scene-root [fill="#fde047"]:not(.watt-meter),
258+
:where([data-skin="pko"]) .city-scene-root [fill="#facc15"]:not(.watt-meter) {
255259
fill: var(--sc-window);
256260
}
257261

@@ -420,6 +424,30 @@ body {
420424
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
421425
}
422426

427+
/* R-11 — CitySkylineHero scoped surface. The hero owns its own
428+
* daylight palette (pko + refreshed catalog colours), so it does
429+
* NOT inherit the `.city-scene-root` `saturate(.35) brightness(1.55)`
430+
* filter that washes cream/ivory building bodies into invisibility.
431+
* Background is white directly; building bodies get a faint navy
432+
* outline so they read against the sky. */
433+
.city-skyline-hero-root {
434+
background: #ffffff;
435+
}
436+
.city-skyline-hero-root rect[fill] {
437+
/* 1 px navy outline (3% opacity) so very-light catalog bodies
438+
* (cream, ivory, light-navy) keep a visible silhouette against
439+
* the daylight sky. Doesn't touch coloured rects strongly enough
440+
* to alter brand colours. */
441+
stroke: rgba(0, 53, 116, 0.18);
442+
stroke-width: 0.5;
443+
}
444+
:where([data-skin="core"]) .city-skyline-hero-root {
445+
background: #07071a;
446+
}
447+
:where([data-skin="core"]) .city-skyline-hero-root rect[fill] {
448+
stroke: none;
449+
}
450+
423451
/* CitySkylineHero empty-state overlay. The original `bg-black/40`
424452
* scrim was tuned for the legacy night sky; on the new pko light sky
425453
* it reads as an unwanted dark wash. Default = light scrim + accent

app/miasto/error.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import Link from "next/link";
5+
6+
/* R-07 — Route segment error boundary for /miasto.
7+
*
8+
* Without this file, an exception inside the WattCityClient bubble
9+
* (typically: stale upgrade state racing a server-side re-render)
10+
* crashed all the way to the browser default error page. Next.js
11+
* App Router automatically renders this component when a server or
12+
* client component inside `app/miasto/*` throws.
13+
*
14+
* `reset()` clears the error boundary and triggers a re-render of
15+
* the segment — the typical fix is "try again" since the underlying
16+
* cause is usually a transient race that resolves on the next
17+
* fetch. We also surface a "Back to home" escape hatch.
18+
*/
19+
export default function MiastoError({
20+
error,
21+
reset,
22+
}: {
23+
error: Error & { digest?: string };
24+
reset: () => void;
25+
}) {
26+
useEffect(() => {
27+
// Server-side aggregator (Phase 5.3 first-party analytics)
28+
// catches this too; client-side log is a development convenience.
29+
if (typeof console !== "undefined") {
30+
console.error("[/miasto] segment error:", error);
31+
}
32+
}, [error]);
33+
34+
return (
35+
<div className="flex flex-col gap-4 max-w-xl mx-auto py-12 animate-slide-up">
36+
<span aria-hidden className="text-5xl text-center">
37+
⚠️
38+
</span>
39+
<h1 className="t-h2 text-[var(--accent)] text-center">
40+
Coś poszło nie tak w Twoim mieście
41+
</h1>
42+
<p className="t-body text-[var(--ink-muted)] text-center">
43+
Spróbuj ponownie. Jeśli problem się powtarza, wróć do strony głównej —
44+
dane są zapisane bezpiecznie.
45+
</p>
46+
{error.digest && (
47+
<p className="t-caption text-[var(--ink-subtle)] text-center font-mono">
48+
ref: {error.digest}
49+
</p>
50+
)}
51+
<div className="flex flex-wrap items-center justify-center gap-3">
52+
<button type="button" className="btn btn-primary" onClick={reset}>
53+
Spróbuj ponownie
54+
</button>
55+
<Link href="/" className="btn btn-secondary">
56+
Strona główna
57+
</Link>
58+
</div>
59+
</div>
60+
);
61+
}

components/city-scene.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1656,10 +1656,23 @@ function WattMeter({
16561656
}) {
16571657
if (cap <= 0) return null;
16581658
const pct = Math.min(1, value / cap);
1659+
// R-09 — `class="watt-meter"` opts the inner fill out of the
1660+
// global `[fill="#fde047"]` retint (see globals.css). The
1661+
// attribute selector matches all neon-yellow fills inside the
1662+
// city scene EXCEPT elements bearing this class — keeps the
1663+
// best-score progress meter visibly yellow on the pko skin
1664+
// (it is a UI signal, not a decorative window).
16591665
return (
16601666
<g>
16611667
<rect x={x} y={y} width={w} height={8} fill="#0a0a0f" stroke="#0a0a0f" strokeWidth={2} rx={2} />
1662-
<rect x={x + 1} y={y + 1} width={(w - 2) * pct} height={6} fill="#fde047" />
1668+
<rect
1669+
className="watt-meter"
1670+
x={x + 1}
1671+
y={y + 1}
1672+
width={(w - 2) * pct}
1673+
height={6}
1674+
fill="#fde047"
1675+
/>
16631676
</g>
16641677
);
16651678
}

components/city-skyline-hero.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,17 @@ export function CitySkylineHero({ buildings, lang, emptyStateCta }: Props) {
8585

8686
return (
8787
<section
88-
// `city-scene-root` opts the hero into the pko skin's
89-
// attribute-selector overrides in globals.css (sky stops, ground
90-
// pattern, stroke colors) and provides the skin-aware background.
91-
className="city-scene-root card relative overflow-hidden"
88+
// R-11 — class swapped from `city-scene-root` to dedicated
89+
// `city-skyline-hero-root`. The hero canvas owns its palette
90+
// (the new R-05 daylight surface + the refreshed catalog
91+
// colours), so the city-scene attribute-selector retints +
92+
// saturate(.35) brightness(1.55) filter no longer apply. The
93+
// prior shared class washed cream/ivory building bodies into
94+
// invisibility because brightness(1.55) on near-white pushed
95+
// them past pure white. The hero gets its own scoped CSS in
96+
// globals.css (sky bg + 1 px navy outline on bodies for
97+
// readability against the light sky).
98+
className="city-skyline-hero-root card relative overflow-hidden"
9299
aria-labelledby="skyline-heading"
93100
>
94101
<h2 id="skyline-heading" className="sr-only">

components/language-switcher.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,26 @@ export function LanguageSwitcher({ current, variant = "header" }: Props) {
5757
});
5858
// Soft transition — `router.refresh()` re-runs the server tree
5959
// (server components, layout, page) with the new `xp_lang`
60-
// cookie and patches the rendered DOM in place. The previous
61-
// `window.location.reload()` killed scroll position, blanked
62-
// the page, and visibly re-mounted client islands; this swap
63-
// looks like a language change, not an app crash.
60+
// cookie and patches the rendered DOM in place.
6461
router.refresh();
6562
setOpen(false);
63+
// R-10 — Next.js 16 RSC cache occasionally returns a stale
64+
// payload after the cookie flip (the layout RSC was closed
65+
// over the prior dictionary at request time). The /api/lang
66+
// route now `revalidatePath("/", "layout")` for the common
67+
// case; this 250 ms guard verifies the rendered `<html
68+
// lang>` actually flipped — if it did not, fall back to a
69+
// hard reload so the user never sees a half-translated page.
70+
window.setTimeout(() => {
71+
try {
72+
const htmlLang = document.documentElement.lang;
73+
if (htmlLang && htmlLang.toLowerCase() !== lang.toLowerCase()) {
74+
window.location.reload();
75+
}
76+
} catch {
77+
/* ignore */
78+
}
79+
}, 250);
6680
} finally {
6781
setPending(null);
6882
}

components/site-nav.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export function SiteNav({
107107
navLinks.push({ href: "/rodzic", label: PARENT_KID_LABEL[lang] });
108108
}
109109
return (
110-
<header className="w-full border-b border-[var(--line)] sticky top-0 z-20 bg-[var(--surface)]">
110+
<header className="w-full border-b border-[var(--line)] sticky top-0 z-40 bg-[var(--surface)]">
111111
<nav className="max-w-6xl mx-auto px-4 sm:px-6 h-[56px] sm:h-[72px] flex items-center justify-between gap-4">
112112
{(() => {
113113
const theme = resolveTheme();

components/watt-deficit-panel.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,13 @@ export function WattDeficitPanel({ deficit, lang }: Props) {
147147
<aside
148148
role="alert"
149149
aria-labelledby="wdp-title"
150-
/* sticky offset: desktop = 64 px (h-16 main nav row only).
151-
Mobile = 144 px because SiteNav stacks main row 64 + secondary
152-
nav ~40 + resource-bar ~40 (all rendered inside the sticky
153-
<header>). Before this fix the panel pinned at 64 px and let
154-
the secondary nav + resource-bar scroll past over it, hiding
155-
the critical-state banner under the nav stack.
156-
The panel is only rendered for authenticated users who are
157-
in deficit, so resource-bar is always present → 144 is safe. */
158-
className="sticky top-[144px] sm:top-16 z-[30] border-b border-[var(--danger)] bg-[var(--surface-2)]"
150+
/* R-08 — sticky offset matches the actual SiteNav heights:
151+
mobile main row 56 + resource-bar 40 = 96 px; sm+ main row
152+
72 + resource-bar 40 = 112 px. Header z-40 ensures the
153+
banner never paints on top of the nav (the prior z-[30] vs
154+
header z-20 had the inversion that made the banner cross
155+
the header on scroll). Bg `--surface-2` is opaque. */
156+
className="sticky top-[96px] sm:top-[112px] z-30 border-b border-[var(--danger)] bg-[var(--surface-2)]"
159157
>
160158
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-2 flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 text-sm">
161159
<div className="flex-1 flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">

0 commit comments

Comments
 (0)