Skip to content

Cyber redesign — Hackers / Strange Days / eXistenZ - #16

Open
lowisren wants to merge 7 commits into
mainfrom
feat/cyber-redesign
Open

Cyber redesign — Hackers / Strange Days / eXistenZ#16
lowisren wants to merge 7 commits into
mainfrom
feat/cyber-redesign

Conversation

@lowisren

Copy link
Copy Markdown
Owner

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

Check Result
npm run a11y (axe-core, 11 routes × 2 themes) 0 violations / 22 runs
npm run check-contrast (design tokens) 76 / 76 pairs
Focus indicators 414 / 414 focusable elements
320px reflow (1.4.10) · 200% zoom (1.4.4) clean on all routes
Typecheck · build clean
Lint 0 errors (2 pre-existing warnings in scripts/)

/search and /_not-found remain statically prerendered.

Commits

Reviewable in order; each is self-contained.

Commit Scope
c737d04 Plan and design direction
ebb7426 Tokens, typography, primitives
fb1cf60 Chrome; dark by default
33ebbb0 Hero, rail, title card
2b35d96 Eight route passes
f85ca82 Motion, texture, reduce-effects toggle
944687b Verification gate: axe + contrast guard

Design

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.css rather than hardcoding them, and it is verified to fail: reverting the light accent to #16a34a produces 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:

  • Light-mode accent at 3.30:1 — every accent link, accent badge and the entire white-on-green primary button failed WCAG 1.4.3
  • Hero bottom vignettes used a theme token behind text-white, so in light mode titles faded into a near-white ground at 1.12:1 (three surfaces affected)
  • Genre hero contrast depended on an editor-chosen CMS colour; a plausible light accent computed to 3.40:1
  • Theme toggle read theme instead of resolvedTheme, announcing the wrong mode to screen readers
  • A hover overlay was aria-hidden while holding a focusable control (axe aria-hidden-focus)
  • Portrait cards in responsive grids could not shrink below 320px

4 regressions introduced during the redesign and caught before merge. Two came from position declared in unlayered utility classes silently overriding Tailwind's positioning utilities — which turned the sticky header relative and collapsed the hero scrim to 0px tall. globals.css now documents that trap where the utilities are defined.

Review notes

  • Two intentional deviations from the plan, both with evidence in the commit messages: persistence uses localStorage rather 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).
  • Stock Tailwind radii are re-pointed at the new sharp scale so existing rounded-* call sites adopt the geometry without a churn-only pass. Easy to revert if you would rather migrate call-by-call.
  • axe-core is the only new dependency; it has none of its own. The npm audit high-severity findings in next, postcss and sharp are pre-existing and unrelated.

🤖 Generated with Claude Code

lo-ctsk and others added 7 commits August 14, 2026 10:15
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>
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.

2 participants