Cyber redesign — Hackers / Strange Days / eXistenZ - #16
Open
lowisren wants to merge 7 commits into
Open
Conversation
Design direction and phased plan for a dark-native rebuild of Flixstack's visual language, drawn from Hackers, Strange Days, and eXistenZ. Records the five resolved decisions (keep light mode, Chakra Petch, restrained effects, add a reduce-effects toggle, foundation-first sequencing) and the six-PR delivery plan. Also documents two accessibility defects found while planning: - light-mode accent #16A34A is 3.30:1 on white, not the 5.0:1 recorded in docs/accessibility.md — a real WCAG 1.4.3 failure - theme-toggle reads `theme` rather than `resolvedTheme`, so with enableSystem it renders the wrong icon and announces the wrong aria-label Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR 1 of the cyber redesign (phases 1-3). Foundation only: the site renders correctly throughout, with new primitives underneath it. Tokens (globals.css) - Four signal hues with distinct jobs, replacing the single utility green that served as accent + success + focus: phosphor (interactive), cyan (info), magenta (live), amber (premium). - Both palettes verified on all three surfaces of their theme — 71/71 text and non-text pairs pass. A token that passes on white can still fail on the elevated fill, so every pair is checked against base, surface, and elevated rather than one representative ground. - Shape, glow, grid, grain and scanline tokens. Fixes WCAG 1.4.3 in light mode - Light accent #16A34A was 3.30:1 on white (documented as 5.0:1). Now #0E7038 at 6.19:1, which also fixes the white-on-green primary button. - Light error and premium were below 4.5:1 on the elevated surface; both darkened. Fixes WCAG 1.4.11 on controls - New --color-border-control, splitting decorative dividers from control boundaries. The search input drew its only affordance with the decorative token at roughly 1.3:1; it now uses the control token. Reduce-effects gating - .reduce-fx zeroes the texture and glow tokens at the token level, so texture switches off everywhere at once and no effect can escape the gate. Its UI ships in PR 5; the contract lands now so effects authored in later phases don't need retrofitting. - forced-colors and prefers-contrast fallbacks brought forward, rather than shipping notched shapes and glow-only affordances without them. Typography - Chakra Petch as the display face, weights 600/700 only — the other cuts would be dead payload for a headings-and-labels face. Geist Mono promoted from a single call site to the data role. Primitives - Button keeps its exact variant/size/loading/asChild API; adds a terminal variant whose bracket affordance is drawn with borders rather than "[" and "]" characters, so screen readers have no punctuation to announce. - Badge gains info and signal variants. - Skeletons move to a scanline sweep. Two defects found while reviewing the primitives in a browser - The inset focus ring on notched elements was invisible on the primary button, whose fill *is* --color-focus-ring. It is now two-tone (ground band + accent band) so one band always contrasts with any fill. Buttons use transition-colors, not transition-all, so focus feedback is immediate rather than fading in over 150ms. - Button variant `secondary` had no visible boundary in light mode, where its elevated fill sits at ~1.06:1 against the page. It now carries a control border, and every variant carries a transparent one so bordered and unbordered buttons match in height side by side. Also - Skeletons were firing up to 15 simultaneous role="status" live regions on the homepage. The atomic Skeleton is now decorative, with a single SkeletonGroup wrapper carrying the announcement. - HeroSkeleton height now matches Hero, removing a load-time layout shift. - Stock Tailwind radii are re-pointed at the sharp scale so existing rounded-* call sites adopt the new geometry without a churn-only pass. rounded-full is untouched — it is the pod curve. - docs/accessibility.md corrected: it asserted three inaccurate ratios and documented an `npm run a11y` script that does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR 2 of the cyber redesign (phase 4). Class strings only, apart from the two theme fixes below. Chrome - Header: phosphor underglow hairline over the existing border, notched logo mark, site name in the display face. - Nav: mono uppercase links with an underscore caret that grows on hover and sits full width on the active route. The caret is driven by [aria-current="page"], which nav.tsx already set, so the indicator derives from real semantics rather than a parallel isActive class. The active route is never marked by colour alone — accent colour, caret, and aria-current. - Footer: wireframe grid backdrop, mono caps column headings, and a "> Built with Contentstack" terminal prompt line. Dark is now the default - defaultTheme "system" -> "dark", keeping enableSystem so System stays selectable and anyone who has explicitly chosen light or system keeps it. Fixes a live theme-toggle defect - theme-toggle read `theme`, which with enableSystem holds the *setting* — the literal string "system" — not the active theme. Every visitor on System therefore got the wrong icon and, worse, the wrong aria-label announced: the page rendered dark while the button said "Switch to dark mode". Now reads `resolvedTheme`. - Verified across all four cases: fresh visitor on a light OS renders dark; explicit light renders light; System on a light OS renders light; and System on a *dark* OS — the case the old code got wrong — renders dark and correctly announces "Switch to light mode". - The toggle gains a [ DAY ] / [ NIGHT ] readout so state is not carried by icon alone. Icon, label, and aria-label all describe the same action, so there is no ambiguity about whether the control shows state or intent. Also - Removes the setState-in-an-effect hydration guard that eslint flagged (react-hooks/set-state-in-effect), replacing it with useSyncExternalStore. The pre-hydration placeholder renders at final size but inert and invisible, so there is no layout shift and no briefly-wrong label. - .grid-backdrop's pseudo-element now sets z-index, and the footer content is wrapped in a positioned element. Positioned pseudo-elements paint above non-positioned inline content, so the grid would otherwise have painted over the footer text. - New --underglow-opacity token, gated by .reduce-fx alongside the other texture tokens. Verified that .reduce-fx zeroes every texture token and the painted pseudo-elements, and that forced-colors drops the notch clip-path back to a real border. - Standardises the touched files on the Tailwind v4 shorthand and drops focus-visible utilities made redundant by the global focus ring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR 3 of the cyber redesign (phase 5). Fixes a live WCAG 1.4.3 failure in the hero - The bottom vignette used `from-background`, a theme token, while the copy is `text-white` in both themes. In light mode that faded white text into a near-white ground: measured 1.12:1 on the title. - The scrim is now two gradients at a FIXED dark ink in both themes, plus scanlines. Measured on real rendered pixels over the bright light-mode artwork: title 16.92:1, subtitle 13.71:1 worst case. Fixes an ARIA violation on the title card - The hover overlay was aria-hidden but contained a focusable watchlist button (axe `aria-hidden-focus`). Keyboard users could focus an invisible control. The overlay is no longer aria-hidden and now reveals on focus-within as well as hover, so the button is visible whenever focused; only the duplicate play link stays hidden and untabbable. Surfaces - Hero: display caps title with the chromatic fringe, corner HUD brackets, notched carousel arrows. Carousel semantics, auto-rotate and hover-pause untouched. - Title card: notched panel, mono tabular metadata, phosphor score readout, and a scan sweep replacing hover:scale-105 — scaling a card forces layout work for every card in a rail, where a pseudo-element transform composites. Also drops the hover useState in favour of CSS group-hover. - Rail: display caps heading with a `//` prefix, notched arrow buttons, and an edge fade. The fade is disabled on :focus-within, because a mask clips painting and would otherwise fade the focus ring of a card scrolled to the boundary. - Video player: container chrome only. Native controls kept deliberately — and no clip-path, which would clip the browser's own control bar. Fixes two cascade regressions from my own utility classes - These utilities are UNLAYERED while Tailwind's are in @layer utilities, and unlayered CSS beats layered CSS regardless of specificity. A `position` in one of them therefore overrode Tailwind positioning silently: * .chrome-underglow turned the sticky header into position: relative (introduced in PR 2 — the header stopped sticking on scroll) * .scanlines overrode `absolute inset-0` on the hero scrim, collapsing it to 0px tall so it painted nothing at all - No utility declares `position` any more; each consumer supplies it with a Tailwind class. Documented at the top of the utilities section so the trap isn't re-introduced. New effects are gated like the rest: --fx-hud-opacity and --fx-sweep-opacity zero out under .reduce-fx, the sweep has an explicit reduced-motion static state, and forced-colors drops the HUD brackets, the sweep and the rail mask while restoring outline-based focus rings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR 4 of the cyber redesign (phase 6). Fixes the same hero scrim defect on two more surfaces - /watch/[slug] and /genre/[slug] both faded to `from-background` behind text-white copy, so in light mode the title sat on a near-white ground — the same WCAG 1.4.3 failure fixed on the homepage hero in PR 3. Both now use the fixed-dark .hero-scrim. Hardens the genre hero against editor-chosen colour - The genre hero tinted itself with `genre.color_accent` and put theme-coloured text directly on that tint, so heading contrast depended on a colour an editor picks in Contentstack. All six current genres passed (worst 5.01:1 computed, 4.61:1 measured), but a plausible light accent — a yellow "Comedy" at #FDE047 — takes it to 3.40:1 in dark mode, and a near black accent takes light mode to 3.49:1. The copy now sits on the same fixed-dark scrim as the other heroes, so it is independent of the field: measured 16.27:1 dark / 17.55:1 light, up from 4.61 / 6.80. Fixes the last lint error in the repo - profile-client used an <a> for internal navigation to /setup (@next/next/no-html-link-for-pages). Now a <Link>. Lint is at zero errors; the two remaining warnings are unused vars in scripts/. Routes - Search: a terminal query line — accent `>` prompt, mono input, and caret-accent so the *native* text cursor is the phosphor caret rather than a decorative animated block (nothing extra to animate or gate). Results read as `[ n matches ]`; the empty state is a "no signal" panel. All the existing aria-live / aria-controls / aria-describedby wiring is untouched. - Not found: a "signal lost" HUD panel with a status strip and a trace-route line. Static — the glitch displacement lands in PR 5 with the motion layer, so this route ships no ungated animation. - Browse: notched filter toggles and segmented controls, keeping aria-pressed and role="radiogroup"/"radio" exactly as they were. - Watch: notched season accordions (still <details>/<summary>), mono sidebar readouts with tabular figures, pod avatars, signal badge for "Now Playing". - Profile: notched panels, notched genre toggles, HUD-slot switches that keep role="switch"/aria-checked, pod avatar and knob. - Setup: deliberately restrained — display headings and notched panels, but code blocks and body copy keep full readability. No copy changes; it is CMS-seeded. - Home: FeatureCallouts becomes a mono "modules on this page" readout. - Genre + modular block renderer brought onto the same vocabulary. Full-route verification sweep - All 11 routes × both themes: no horizontal overflow, header sticky everywhere (the regression PR 3 fixed stays fixed), and every h1 measured against its real rendered background — worst 13.85:1. - Console checked on 7 routes: 4 completely clean. The remainder are pre-existing and unrelated (a script-tag notice from the Lytics component in the root layout, an LCP hint on TitleCard images, and a Chrome extension artifact). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR 5 of the cyber redesign (phase 7). Texture - CRT scanlines and grain now paint as part of the body BACKGROUND rather than a fixed overlay. An overlay would put texture on top of poster art and body copy, which the design guardrail forbids; a background layer sits behind every surface and keeps text contrast computable. Alpha comes from the gated tokens via color-mix, so .reduce-fx switches it off with everything else. - Wireframe grid added to /setup, completing the phase 7 surface list. Glitch displacement (not-found only) - Three iterations, then still for good. Displacement plus a chromatic offset, never a luminance strobe, so it is not a "flash" under WCAG 2.3.1 — and the target is a small decorative numeral, far under the large-flash area threshold. Still motion, so it is gated by prefers-reduced-motion and by the --fx-glitch token. Reduce-effects toggle - role="switch" control in profile preferences and the footer, persisted to localStorage, applied as the .reduce-fx class on <html>. Persistence: localStorage, not a cookie — reversing the plan's recommendation - The plan preferred reading a cookie server-side. The docs are explicit that `cookies()` "will opt a route into dynamic rendering", and the build shows /search and /_not-found are currently static, so a cookie read in the root layout would deoptimise them. Verified they are still static after this PR. - Not next/script beforeInteractive either: that strategy explicitly "does not block page hydration", which would let effects paint once before being suppressed — the exact flash this preference exists to prevent. A blocking inline script matches what next-themes already does in this app. Fixes a bug the inline script alone could not cover - The preference persisted on /, /search and /profile but silently failed on the 404 route. That route 307-redirects and renders its shell on the client, where inline scripts inside React components never execute — the condition the dev overlay had been warning about all along. The hook now reconciles the class from storage on mount as a safety net: a no-op whenever the script ran, so it costs at most a single frame, and only on paths where the script cannot run. Verified across /, /search, /profile and the 404 route. Verified gating end to end: default on, toggle zeroes every texture token and stops the glitch, the preference survives reload, and prefers-reduced-motion independently kills motion with the toggle off — the OS setting always wins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR 6 of the cyber redesign (phase 8). Closes the redesign. Two committed checks, both exiting non-zero so either can gate CI - `npm run a11y` — axe-core across 11 routes x 2 themes, writing reports/a11y-report.json. docs/accessibility.md documented this script for a long time without it existing; this is it. Drives headless Chrome over CDP, so it adds no Selenium/Playwright dependency and axe-core itself has none. - `npm run check-contrast` — parses the real token values out of globals.css and checks all 76 shipped pairs. Verified it actually fails: reverting the light accent to #16a34a produces 5 errors, including the 3.30:1 primary button. A guard that cannot go red is not a guard. Result: axe 0 violations across 22 runs; contrast 76/76. The audit found 246 violations in three classes, all real - 231 x color-contrast: --color-text-disabled (3.41:1) was used for card genre names and other readable content. The token is documented as "non-essential text" but WCAG 1.4.3 exempts *inactive components*, not quiet ones. It is now restricted to decorative aria-hidden icons; content uses text-secondary. - 13 x link-in-text-block: inline links in prose were distinguished by colour alone (WCAG 1.4.1). Now always underlined. - 2 x aria-prohibited-attr: aria-label on a div with no role. Now role="img". Then two more, from the last 3 violations - getRatingColor returned raw Tailwind palette classes, bypassing the token system and failing contrast at 3.73:1. Now design tokens. - Those classes used `dark:`, which in Tailwind v4 keys off prefers-color-scheme — but this app themes with a `.dark` class. So they tracked the OS, not the app: with dark now the default, a visitor on a light OS got dark surfaces and light-mode `dark:` values. Added `@custom-variant dark`. No `dark:` utilities remain today, so this is preventive. Keyboard, reflow and zoom - 414 focusable elements across 7 routes: every one has a visible focus indicator (outline, box-shadow, or an inset ring on its notched ancestor). - 200% zoom: clean everywhere. - 320px reflow (WCAG 1.4.10) failed on every route and needed three fixes: portrait TitleCards in responsive grids kept their fixed 160px width and could not shrink (`fullWidth` is exactly the documented escape); /setup prose could not break long env-var names; and the display-caps header wordmark — introduced in PR 2 — pushed the header to 349px, so it now hides below `sm` where the logo mark and the link's aria-label already carry the site name. Diagnosing this needed care: the header looked like the cause but was a consequence of stretching to an already-too-wide document. docs/accessibility.md rewritten - Real audit commands, per-theme token tables verified on all three surfaces, keyboard/reflow/zoom results, and a 15-row log of every defect found and fixed across PRs 1-6 — including the two regressions the redesign introduced and caught (the unlayered-`position` cascade trap, and the reduce-effects preference failing on the client-rendered 404 shell). Verified /search and /_not-found are still statically prerendered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A dark-native rebuild of Flixstack's visual language, drawn from three 1990s cyber films. Dark becomes the default; light mode stays, redesigned as a plainer "daylight terminal" variant.
Plan and full ratio tables:
docs/cyber-redesign-plan.md.Verification log:
docs/accessibility.md.Verification
npm run a11y(axe-core, 11 routes × 2 themes)npm run check-contrast(design tokens)scripts/)/searchand/_not-foundremain statically prerendered.Commits
Reviewable in order; each is self-contained.
c737d04ebb7426fb1cf6033ebbb02b35d96f85ca82944687bDesign
Four signal hues replace the single utility green that previously served as accent, success and focus ring: phosphor green (interactive), cyan (informational), magenta (live), amber (premium). Chakra Petch as the display face at weights 600/700 only; Geist Mono promoted from one call site to the data role. Notched HUD geometry, CRT scanlines, wireframe grids and a chromatic fringe.
The governing guardrail: artwork is the hero. Texture is confined to chrome and never overlays poster art or body copy. That is both the design principle and the accessibility strategy — if texture never touches text, text contrast stays computable. It is why the CRT layer paints in the body background rather than as an overlay.
Accessibility
Two checks ship with the repo, both exiting non-zero so either can gate CI. The contrast guard parses real values out of
globals.cssrather than hardcoding them, and it is verified to fail: reverting the light accent to#16a34aproduces 5 errors. A guard that cannot go red is not a guard — and this work began because a hand-written number in the docs claimed 5.0:1 for a value that measured 3.30:1.11 pre-existing defects fixed, including:
text-white, so in light mode titles faded into a near-white ground at 1.12:1 (three surfaces affected)themeinstead ofresolvedTheme, announcing the wrong mode to screen readersaria-hiddenwhile holding a focusable control (axearia-hidden-focus)4 regressions introduced during the redesign and caught before merge. Two came from
positiondeclared in unlayered utility classes silently overriding Tailwind's positioning utilities — which turned the sticky headerrelativeand collapsed the hero scrim to 0px tall.globals.cssnow documents that trap where the utilities are defined.Review notes
localStoragerather than a server-side cookie (cookies()would deoptimise the static routes), and the CRT texture is a background layer rather than an overlay (an overlay would breach the artwork guardrail).rounded-*call sites adopt the geometry without a churn-only pass. Easy to revert if you would rather migrate call-by-call.axe-coreis the only new dependency; it has none of its own. Thenpm audithigh-severity findings innext,postcssandsharpare pre-existing and unrelated.🤖 Generated with Claude Code