Skip to content

feat(uxcore): dark mode across UX Core + layout/context fixes#114

Merged
MaryWylde merged 26 commits into
devfrom
feat/uxc-dark-2
May 15, 2026
Merged

feat(uxcore): dark mode across UX Core + layout/context fixes#114
MaryWylde merged 26 commits into
devfrom
feat/uxc-dark-2

Conversation

@manager
Copy link
Copy Markdown
Contributor

@manager manager commented May 15, 2026

Summary

Dark-mode pass across UX Core (now in this repo post-consolidation), plus the layout/context plumbing fixes that had to land alongside it.

  • Dark theme across UXCP (decision table, country/bias map, pagination, persona, selection, suggested questions, switchers, tab headers), UXCG (stage filters, panel/tag), UXCat (start-test / ongoing / result / certificate, achievements, completion bar, tooltips, footer, accordion), plus shared chrome: modals (Log In, Our Projects, Magic Link, UXCP Log In), tooltips, dropdowns, user profile, language flags, inputs/textareas, tags, buttons, tables.
  • Layout & context fixes that unblocked the dark work: route-split _app so UX Core pages get UX Core's Layout; real GlobalContext.Provider bridged in _app; bias data passed as prop to /uxcore/[slug] and /uxcg/[slug]; UX Core @font-face consolidated into globals.scss; App-Router usePathname() dropped from a Pages-Router layout; router.asPath hydration leaks on /uxcore#hr fixed.
  • Cross-realm theme sync so the toggle stays consistent between keepsimple and UX Core surfaces.
  • Polish: kill modal-open layout shift, modal scroll + fade transitions, visible X icon in Log In modal, clickable View-type + Use-cases switchers, selected navbar icons stay visible on hover, cycling subtitle word visible on dark, stage-pill label visible, /uxcat accordion chevron + section titles + tooltip body visible.
  • next.config.js: allow the flag CDN host needed by the country/bias map.

Test plan

  • DEV (keepsimple.administration.ae) pointed at this branch and refreshed
  • Dark mode on /uxcore, /uxcg, /uxcat, /uxcp — no white flashes, readable text, modals intact
  • /uxcat full flow start → ongoing → result → certificate in dark
  • /uxcp decision table + country/bias map + pagination in dark
  • Log In + Our Projects + Magic Link modals in dark, no layout shift on open
  • Theme persists when crossing keepsimple ↔ UX Core routes
  • Language flags still coloured; bias-popup tooltip readable in dark
  • next build clean (Strapi guardrails still pass)

manager and others added 25 commits May 15, 2026 08:40
- /uxcore/[slug] now fetches UXCG questions in getStaticProps and feeds
  them via prop; the old read-from-GlobalContext path saw the null
  default (provider wires uxcgLocalizedData: null in _app.tsx) and the
  modal silently dropped its "This bias answers the following questions"
  section.
- UXCG stage selector: invert + dim the unselected light-grey tile image
  in dark mode (selected tiles untouched, keep their colored bg).
- UXCG PanelHeader: invert dark monochrome icons in dark mode so they
  read against the dark panel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- LanguageSwitcher: dark dropdown bg + light link/hover so the panel
  stops leaking light-theme look inside the bias modal; flag SVGs now
  show on a matching dark surface.
- Table show-more/less buttons: explicit dark border-color so the light
  default 1px border stops drawing a frame against the dark panel.
- Input clear icon: invert filter so the dark monochrome × is visible
  against the dark input.
- UserDropdown "Log In" / username: lift base text from rgba(0,0,0,.85)
  to the dark-theme foreground; same for hover/active.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
usePathname() from next/navigation is App-Router-only. In a Pages
Router app it returns undefined during SSR and the real path on the
client, so every conditional that branches on it (isUXCoreRoot,
isUXCoreNested, shouldHideToolHeader) flipped between server and
client render and produced a hydration mismatch on /uxcore (and any
UX Core route). Swap to router.pathname which is identical in both
phases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
router.asPath includes the URL hash on the client but is hash-free
during SSR. Four render-time comparisons against asPath === '/uxcore'
(or /uxcg) were flipping between server and client whenever the URL
carried a hash (e.g. /uxcore#hr), producing a second hydration
mismatch even after the previous usePathname() fix.

- MobileHeader: gate the podcast button on pathname instead of asPath
- ToolHeader: same for the two header tooltips and the desktop
  podcast button; introduce a local pathname constant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two sidebar switchers on /uxcore relied on a
dataset.type === defaultViewLabel comparison that silently no-op'd
whenever defaultViewLabel was undefined — which is exactly how
UXCoreLayout calls the "View type" switcher (no defaultViewLabel
prop). On a fresh load, the only ever-clickable button was the one
whose state was already active, so neither button toggled.

Drop the dataset trick: each button knows whether it's the first or
second slot and toggles only when clicked from the opposite side.
Also guard handleSnackbarOpening so the Use-cases pair stops throwing
when the "View type" pair (no snackbar prop) is clicked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per review on #113: the UX Core font declarations + the scoped
body.uxcorePage default no longer live in a sibling stylesheet. The
@font-face block and the body.uxcorePage Lato fallback move into
src/styles/globals.scss, the separate src/uxcore/styles/uxcore-fonts.scss
is deleted, and the extra import in _app.tsx is dropped.

The body.uxcorePage rule stays placed AFTER the global `*` selector
so specificity still wins over the Source-Serif default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- LanguageSwitcher: explicitly null any inherited image filter on the
  flag SVGs in dark mode so the colour stripes survive when the
  switcher sits inside a modal header whose dark rules apply
  saturate(0) / invert to images.
- Tooltip popup: dark surface + arrow recolour for the non-Dark
  variant so the AnswerBiasLink hover popup inside the UXCG modal
  stops rendering as a white card on a dark modal.
- BiasPopupContent: lift link colour and muted tip line for the dark
  surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The dark override applied the dark-monochrome invert/brightness filter
to every navbar SVG, including the selected (Active / ActivePodcast /
ActiveProjects) items whose base path fill is already #fafafa. On
hover, the filter combined with the lighter hover background flattened
the icon to dark grey on dark grey — effectively disappearing. Null
the filter on selected variants (and their hover state) so the white
fill wins.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two compounding bugs were silencing the right-sidebar switchers:

1. .coreView (the hexagon/spider page) is sized 100vw x 100vh and is
   rendered AFTER the two .viewTypeSwitcher / .viewTeamSwitcher
   siblings in the DOM. Without a z-index, the core view layer stacks
   on top of the absolute-positioned switchers and absorbs every
   click that lands on them. Lift both switchers to z-index: 3.
2. The earlier "click any inactive button toggles" refactor had the
   active/inactive sides inverted. Restore the correct semantics:
   first slot is active when isSecondView=true (FolderView class),
   second slot is active when isSecondView=false (CoreView class).
   Toggle fires only from the inactive side.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inactive stage tag pills sat at 0.3 opacity, which disappears against the
dark page background — including the "All Questions" button whose inline
#282828 bg matched the page bg almost exactly. Lift inactive pills to 0.75
with a faint border and re-skin "All Questions" to a lighter neutral so
the row reads as a real filter strip in dark theme. Also capitalize the
label to "All Questions" for consistency with the other pill labels.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ut shift

Dark theme for the shared Modal chrome (bg, header divider, title, close
icon), the OurProjectsModal rows / GitHub-API outline buttons / divider,
the LogInModal title / description / provider buttons, and the neutral
Button variant (so the Cancel button reads on dark). Primary / Orange /
BlueOutline button variants are left alone since they keep their brand
colors.

Also fixes the long-standing page-shift-on-modal-open: the old
.hide-body-move rule added margin-right: 8px to <body> when a modal
opened, which only partially compensated for the ~15px scrollbar (net
visible jump). Switch to scrollbar-gutter: stable on <html>, which
reserves the scrollbar's space at all times so toggling overflow:hidden
on modal open no longer reflows the page sideways.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dark theme for the Contact Us modal (CustomModal chrome, body copy and
inline links, Input + Textarea fields). Input + Textarea now flip
globally on dark, which fixes other forms that reuse them too.

Cross-realm dark-theme sync: keepsimple-side and UX Core-side each ship
their own copy of useGlobals with separate module state. Both write to
the same localStorage key and the same body class, so persistence was
fine — but in-memory state diverged when the user toggled in one realm
and navigated to the other, leaving the dark/light toggle button out of
phase with the actual page. Wire a window 'darktheme:change' custom
event that every toggle dispatches and that both realms subscribe to, so
the realm you didn't toggle in mirrors the new value into its own state
immediately. Also fix the init paths to apply the persisted value
unconditionally (was only re-applying when true), and bootstrap the body
class from localStorage at the _app root so deep-links to /uxcore etc.
also pick up the persisted theme.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The bare hr inside OurProjectsModal.module.scss's :global(body.darkTheme)
block was rejected by CSS Modules ("not pure — selectors must contain at
least one local class or id"), failing the DEV build. Move the dark-mode
divider rule into uxcore/styles/globals.scss under body.darkTheme where
a bare element selector is allowed, so every <hr> in dark mode gets the
same divider treatment instead of one modal carrying its own copy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…odal

UX Core LogInModal: the Twitter / X provider icon is a single-path SVG
hard-coded to fill #000, which disappears against the dark provider
button bg. Switch the uxcore copy of XIcon to fill="currentColor" so it
inherits the link's text color — light grey in dark mode, black in
light mode (matches the old visual).

KeepSimple-side LogIn modal: this is a different component (different
visual design, different Modal chrome) and was never dark-themed nor
bounded to viewport height. Two problems fixed:

1. Position. A tall content tree (six provider buttons + email form)
   exceeded 100dvh and the centered modal clipped above and below the
   viewport. Bound the wrapper with max-height: calc(100dvh - 32px) and
   give the body flex: 1 + min-height: 0 so its overflow: auto engages.

2. Dark theme. Re-skin the wrapper (drop the paper-texture bg image and
   apply a dark surface), the header copy, the LogIn heading and
   subtitle, the Google button (gets a faint border so it doesn't blow
   out against the dark wrapper), the error banner, and the MagicLink
   email form (divider, label, input, submit, banner, confirmation).
   Brand-colored provider buttons (Discord / LinkedIn / X / Mail.ru /
   Yandex) keep their own colors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three pieces:

1. Modal scrollbar. The newly-bounded keepsimple modal body was using the
   default browser scrollbar (wide, white). Apply the same slim thumb
   treatment used elsewhere on the site so the scroll surface reads as
   part of the modal chrome instead of an OS widget.

2. Open / close fade transitions for the UX Core Modal and CustomModal.
   The keepsimple-side Modal already had wrapperIn / wrapperOut +
   overlayFadeIn / Out animations and a 180ms delayed-unmount close. The
   UX Core copies had neither, so modals popped in and out. Add the same
   pattern (closing state, delayed onClose, CSS animations) to both UX
   Core Modal and CustomModal so every modal site-wide animates in and
   out instead of snapping.

3. UXCP dark theme. The /uxcp page (UX Core Persona) was light-on-light
   with the dark page bg, leaving every card and form unreadable. Add
   dark-mode overrides for the layout headings (UX CORE PERSONA title,
   subtitle, Motto, section Heading), the shared Section card chrome,
   CountryBiasMap (search input, country grid cards, selected card,
   show-more / show-less buttons), BiasPanel (the selected-country
   detail card, bias chips, help popover, Use button), BiasItem (bias
   rows in the persona builder), PersonaButton (Copy URL button neutral
   variant), Switcher (All / High / Medium / Low filter pills),
   TabHeader (tab card with hover state), and BiasSearch (scrollbar
   thumb). Brand-colored buttons keep their colors.

Also a flag-display fix on the country picker: flag images come from the
flagcdn.com external CDN, which is commonly blocked by ad-blockers and
some restrictive network policies — that's why every flag row showed a
broken-image icon. Add an onError fallback that switches to a Unicode
regional-indicator emoji flag (🇦🇷, 🇦🇲 etc.) so the row stays readable
when the CDN is unreachable instead of showing a broken-image glyph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1. Modal-open scrollbar shift on every page. The previous scrollbar-gutter
   fix lived in uxcore/styles/globals.scss — but that file is not imported
   anywhere, so the rule was dead code. Move it to src/styles/globals.scss
   (the file _app.tsx actually loads) and make it unconditional on <html>.
   The page now reserves the scrollbar's gutter at all times, so opening
   any modal — which locks scroll by setting overflow-y: hidden on <html>
   — no longer reflows the layout sideways.

2. Modal body scrollbars. The keepsimple Modal already got a slim styled
   scrollbar; bring the UX Core Modal body and the CustomModal body into
   the same shape (6px width, soft blue thumb) with dark-theme variants
   (light-blue thumb on dark surface).

3. UXCP dark-theme gaps. Several surfaces were still light-on-light:
   - The "Choose your Rival" outer card carried a hard-coded white bg
     and dark text. Clear the bg and lift the eyebrow / subtitle / lead
     to dark-theme values.
   - The CountryMap label text-shadow was hard-coded white (designed to
     glow against the light map), which left a white halo on dark.
     Flip to a dark glow and dark-theme the hover tooltip.
   - The UXCPDescription welcome paragraph and "on Medium via this link."
     inline link were hard-coded dark, invisible on dark bg.
   - Lift the BiasPanel rationale and bias-chip description from a 0.7
     alpha to solid #c8c8c8 — the dim text was hard to read on dark.
   - Slim styled scrollbar on the inner bias-chip description (it
     overflows on long bias descriptions).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two real-world bugs Wolf hit on DEV that did not reproduce on prod:

1. UXCP appeared light-on-light in dark mode. The body.darkTheme rule
   was setting only `background-color: #1b1e26`, but the base body rule
   used `background: linear-gradient(...) #f9fafb` (shorthand: light
   gradient image PLUS color). The shorthand sets background-image as
   well, and the dark-theme override only changed the color — the LIGHT
   gradient kept rendering on top, so anywhere a child surface did not
   carry its own dark bg (like UXCP), the page leaked back to white.
   Use the full `background:` shorthand on body.darkTheme so the image
   is also cleared.

2. Country flags were blocked by CSP, not by an ad-blocker. The site's
   img-src directive in next.config.js whitelists the strapi hosts +
   Google + Discord but does NOT include flagcdn.com — so every flag
   request was refused by the browser with a CSP violation, which is
   why the same flagcdn URLs that load on prod failed here. Prod must
   have been built before the CSP tightened; DEV has the latest config.
   Add https://flagcdn.com to the img-src allowlist.

Plus: LogIn modal provider buttons now have proper hover + active +
keyboard-focus states (lift on hover, depress on click, brand-color
darken on hover for each provider). And bump UXCP "(UXCP)" subtitle
contrast from a 0.6-alpha gray to solid #a8a8a8.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wolf hit four remaining light-on-dark surfaces on /uxcp:

1. Bias-row strip ("Add biases to Persona" list). BiasActionCell used a
   pale blue bg with a hard-white Add button — both blew out on dark.
   Re-skin the row (faint blue tint, light text, dark Add button with
   blue-accent hover) and invert the remove-icon.

2. SelectionView right panel. The "John Doe" persona-name header and
   the "Add bias to begin" placeholder were hard-coded mid-gray and
   washed out on the dark panel. Lift to readable light-on-dark values.

3. PersonaRelatedQuestions / PriorityFilter. The "Relevance level / All
   / High / Medium / Low" filter strip carried a hard white bg, and the
   pill buttons were white-on-light-border. Clear the strip bg and flip
   the inactive pill to a dark surface (active blue pill is kept). Also
   re-skin the question-list zebra striping and the placeholder.

4. Decision Table. Wrapper border, header row bg, alternating row bg,
   hover row bg, the gradient text-fade endpoint (was fading cells to
   white — now fades to the dark panel), the empty-cell color, the
   footer divider, the Save button neutral state, and the error-message
   callout were all light-theme leftovers. Plus give the TabHeader pink
   variant ("Decision Table example") a dark-mode counterpart so its
   wedge doesn't blow out against dark.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Five things Wolf flagged on /uxcp dark:

1. Decision Table cells were rendering as blurred gray smudges. The
   light-theme rule uses background-clip:text + a gradient that fades
   long cells to white at the bottom; my dark counterpart kept the trick
   with a dark gradient, but short text ends up entirely inside the fade
   band and reads as a blurred rectangle. Disable the clip-text trick on
   dark and paint solid #dadada — text is readable, the trade-off is no
   bottom-fade on overflow but the cell already truncates with
   ellipsis-via-line-clamp so the fade was decorative.

2. Stage-indicator pill ("Released stage" etc.) rendered with a
   saturated brand bg and the label looked black. The DynamicButton sets
   color:#fff on the button but a downstream cascade was dimming the
   inner span. Force #fff on the button + inner Title in dark mode.

3. Pagination active/inactive states read inverted on dark — the
   "inactive" white pill looked more selected than the dark-blue
   "active" one. Flip the palette: inactive page sits on a dark surface
   with light text + faint border (hover lifts to blue accent), active
   page is the brighter #5396d3 blue pill. Also pad the click target
   and add a 4px gap between pages so it feels less cramped.

4. Suggested questions copy + link colors were leaving body text dim
   and the per-question anchor stuck at the dark-blue light-theme color
   on dark. Lift section copy to #dadada and links to #7fb3d5.

5. SuggestedQuestions empty state ("no data") was at #bfbfbf — too
   bright on dark, distracting. Drop to rgba(218,218,218,0.45).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The "Choose your <X>" subtitle on /uxcp cycles a word through several
values (e.g. Rival / Customer / Audience / Persona). The component set
color inline — `#337AB7` for the bold word ("Persona") and `#1A1A2E`
for the rest. The near-black non-bold color was invisible on the dark
page bg, so only "Persona" showed up while every other rotation looked
blank.

Move the colour + font-weight off the inline style and onto two CSS
classes (CyclingWord / CyclingWordBold) so the dark-theme rule can
override: non-bold lifts to #dadada, bold stays a light blue accent
(#7fb3d5). Light theme keeps the original colors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two follow-ups on /uxcp dark:

1. Stage-indicator pill ("Released stage" etc.). Previous attempt set
   color:#fff !important on .DynamicButton, but the cascade was still
   reaching the inner `.Title` text node through body.darkTheme's
   inherited #dadada via specificity. Apply the white override to every
   child node (.DynamicButton, .Titles, .Title) AND darken the saturated
   brand background ~20-25% on each Active state so the white label has
   real contrast (the coral and orange variants in particular were
   washing the label out at full saturation).

2. Persona-relevant questions rows. RelatedQuestion.Text was hard-coded
   #000 and the divider #e9e9e9 — both visible on light, both wrong on
   dark. Lift the question text to #dadada (#fff on hover), the bias
   number to the #7fb3d5 accent, and the row divider to #303338.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /uxcat route stack had no dark-mode coverage — every visible surface
across the four pages (landing, start-test, ongoing, test-result) was
light-on-light against the dark page bg. Adds :global(body.darkTheme)
blocks to:

LAYOUTS
- UXCatLayout — page bg clears, title flips to the #7fb3d5 accent,
  subtitle to a readable mid-gray.
- OngoingLayout — question card chrome, question text, bias-info chip,
  answer rows + answer-prefix tag + selected-state border, skeleton
  placeholders, and the "btn icon" SVG fill in the footer.
- TestResult — section header card, encourage / additionalInfo split
  panel + center divider, reading-list links, "next test available"
  date accent.
- StartTestLayout — duration badge (desktop + mobile variant) and modal
  description text.
- CalculatingResults — info card, loader spinner border, progress-item
  track.
- CertificateLayout — footer button bar.

COMPONENTS
- UserProfile — name/title/level/awareness-points/user-statistic color
  shades, badge tooltip.
- Result — passed/failed result cards, question pills, hover state,
  "failed" row tint, bobIcon container.
- CompletionBar — bar surface, mainTitle, level text, base track, step
  number color, hover tooltip background.
- AchievementContainer — grid container surface + mainTitle.
- TestResultsAchievements — card surface + achievement title.
- UXCatFooter — contact-us copy, mail/telegram links, motto accent.

Brand-coloured accents (orange title in StartTest, the level-up gold
gradients in CompletionBar, etc.) are left untouched — they read on
dark already and changing them would lose intent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wolf hit four remaining surfaces on /uxcat that were still light-on-dark:

1. Rules accordion body — the Accordion's existing dark theme lived under
   a `&.darkTheme` modifier that only kicks in when the component opts
   in by setting that class on itself. UXCat does not, so the body
   stayed white-on-light. Auto-apply via `:global(body.darkTheme)` so
   every accordion picks up dark without per-component plumbing — title
   bar, content surface, border, download-button accent.

2. UserProfile cover. The header card sets `background-image` inline
   from JS pointing at the light pastel coverImage.png (or a Strapi
   cover). Cover image kept rendering on top of the dark page bg with
   washed-out "Guest User" text on it. Override with `!important` to
   drop the image in dark mode and use flat #15181f (guest) / #1b1e26
   (logged-in) surfaces. Plus a faint border + radius so the panel
   reads as a card.

3. Level-progression + achievements hover tooltips. Both used a
   `.tooltipContainer` class on react-tooltip, which portals the popup
   to <body>. The previous dark-mode rules were descendant-scoped
   (`.progressBarWrapper .tooltipContainer`, `.userProfile .badgeTooltip`)
   so they never matched the portaled DOM. Move to flat
   `:global(body.darkTheme) .tooltipContainer` rules so the popovers
   actually flip to the dark surface.

4. "Show all achievements" outline button + every other OrangeOutline /
   BlueOutline button on dark — the variants kept their white bg.
   Switch to a transparent surface in dark theme so the brand border +
   text read on the dark page; keep the brand colors untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tip body

Three more dark-mode leaks on /uxcat:

1. Accordion chevron. The collapse/expand caret is rendered from
   /assets/icons/caret.svg by default and only swaps to caret-dark.svg
   when the parent passes isDarkTheme={true} — UXCatLayout does not.
   Result: dark caret on dark title bar, invisible. Invert the icon
   via CSS filter inside the body.darkTheme Accordion rule so it shows
   white regardless of which sprite shipped.

2. Section titles "Level Progression" / "Achievements" / etc. These
   render via the shared UXCatPageTitle component whose .pageTitle
   class was hard-coded to rgba(0,0,0,0.65) — invisible on dark.
   Lift to #dadada in dark mode.

3. Achievement / level hover tooltip body. The .tooltipContainer
   wrapper got a dark surface in the previous pass, but the text
   actually inside (UXCatTooltip — title, description, unlocked /
   statistics labels, points-to-next-level color) was still rendered
   in #000000a6 / #000000d9 / #5e62a7 / #a6a6a6. Lift each to the dark
   palette so the popover body reads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@MaryWylde
Copy link
Copy Markdown
Contributor


This is a large, well-structured PR. The dark-mode coverage is thorough and the architectural fixes (router.asPath → router.pathname, uxcgLocalizedData as prop, cross-realm sync) are sound. A few issues worth addressing before merge.


Issues

1. Duplicate @font-face declarations (Medium)

src/uxcore/styles/globals.scss still retains the full set of font-face rules (Lato, IBM Plex Mono, Oswald, RedHatDisplay, Tomorrow*, DelaGothicOne, Manrope, IBMPlexSans, etc.) — the same block that was also added to src/styles/globals.scss. The deleted file was uxcore-fonts.scss; the uxcore globals itself was only modified, not cleaned up.

Since no TypeScript/TSX file explicitly imports src/uxcore/styles/globals.scss and no sassOptions.additionalData was found in next.config.js, it's unclear whether that file is still loaded at runtime. If it is still loaded (e.g., via a sub-app config or dynamic import chain), browsers will parse the same @font-face descriptors twice — harmless but wasteful. If it isn't loaded, the block is dead weight.

The safe path is to remove the @font-face declarations from src/uxcore/styles/globals.scss (they now live in the canonical src/styles/globals.scss) and leave only the rules that are uxcore-specific (body resets, scrollbar styles, dark-theme body rules). And keep in mind that all the font families are stored in global.scss

Fix this →


2. Stale closure in keyboard handler — CustomModal.tsx (Low-Medium)

CustomModal.tsx registers the Escape listener with an empty [] dependency array, so it captures the handleClose function at mount time. But handleClose closes over closing state. During the 180ms fade-out window, pressing Escape a second time calls the stale handleClose which sees closing === false and starts a second close cycle.

// CustomModal.tsx — useEffect with [] captures initial handleClose
useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Escape') handleClose(); // stale closure over `closing`
  };
  // …
}, []);

The window is short and in practice harmless, but adding handleClose to the dependency array (or using a ref) would make the guard if (closing) return reliable. Modal.tsx has the same pattern.

Fix this →


3. UserProfile.module.scss — split dark-theme block (Low)

The dark theme rules are split into two separate :global(body.darkTheme) .userProfile selectors instead of one. The second block's rules would merge cleanly into the first.

// Two separate :global(body.darkTheme) .userProfile blocks
// at src/uxcore/components/UserProfile/UserProfile.module.scss
:global(body.darkTheme) .userProfile { background-image: none !important; … }
:global(body.darkTheme) .userProfile { .wrapper { … } }  // ← can be merged

No runtime impact, but it's slightly confusing.


4. scrollbar-gutter: stable + overflow-y: overlay interaction (Low, known limitation)

Setting scrollbar-gutter: stable on <html> while overflow-y: overlay is also set may not prevent layout shift on macOS/Chrome where overlay scrollbars don't occupy space — the gutter reservation only applies to "classic" (non-overlay) scrollbars per the CSS spec. On those platforms, the old margin-right: 8px trick (now removed) was actually more reliable. Worth testing on a Windows/Linux browser with classic scrollbars as the primary validation platform for this fix.


5. Oswald-Bold missing file extension (Pre-existing, now duplicated)

src: url('/fonts/biases/Oswald-Bold') format('truetype');

No .ttf extension. This was already in uxcore/styles/globals.scss and is now also copied to src/styles/globals.scss. The browser may still load it (some are lenient), but it's technically incorrect. Not a regression in this PR but worth a follow-up.


Positives

  • background: #1b1e26 vs background-color — Correct fix. Setting only background-color left the light gradient image composited on top of it. Using the shorthand clears the image component.
  • router.pathname instead of router.asPath — Correct fix for both ToolHeader.tsx and MobileHeader.tsx. asPath includes the hash on the client but not on SSR, causing hydration mismatches on /uxcore#hr.
  • usePathname()router.pathname in Layout.tsx — Correct; usePathname() is App Router-only and shouldn't be used in a Pages Router layout.
  • uxcgLocalizedData as prop in /uxcore/[slug] — Moving from useContext(GlobalContext) to a static prop is cleaner and removes a context timing dependency. The Promise.all([getStrapiBiases(), getStrapiQuestions()]) in getStaticProps is also a good parallel-fetch improvement.
  • FlagImage emoji fallback — Well-implemented resilience: onError triggers emoji rendering via Unicode regional indicator codepoints, avoiding broken-image icons when flagcdn.com is blocked.
  • XIcon with currentColor — Makes the icon theme-aware without any JS, the correct approach for SVG icons.
  • Cross-realm theme sync — The darktheme:change CustomEvent pattern is pragmatic and correct. The if (state.isDarkTheme !== next) guard prevents re-dispatch loops. The cold-load bootstrap in _app.tsx's useEffect correctly syncs state on deep-links.
  • Modal animation system — The closing state + 180ms setTimeout pattern for fade-out before unmount is clean. Both Modal.tsx and CustomModal.tsx apply it consistently.
  • CSP update — Adding https://flagcdn.com to img-src is the correct companion change for FlagImage.
  • handleSnackbarOpening guard in ViewSwitcher — Adding && handleSnackbarOpening before calling it prevents a potential undefined call. The new handleFirstClick/handleSecondClick refactor produces identical behavior to the old data-type-based switch but is more readable.

Summary

The dark-mode coverage is comprehensive and the approach (:global(body.darkTheme) selectors in scoped SCSS modules) is consistent throughout. The layout/context fixes are individually correct and well-commented. The two things most worth addressing before merging are the duplicate @font-face block in uxcore/styles/globals.scss (cleanup the leftover from the consolidation) and the stale closure in the modal keyboard handlers (minor risk but easy to tighten up).

return (
<span
className={styles.CyclingWord}
className={`${styles.CyclingWord} ${isBold ? styles.CyclingWordBold : ''}`}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use classnames for these cases.

@keepsimpleio keepsimpleio deleted a comment from claude Bot May 15, 2026
Per Mary's review:

- Remove duplicate @font-face block from src/uxcore/styles/globals.scss
  (lines 90-258 in the previous state). The same declarations were
  consolidated into src/styles/globals.scss in 2fa2586; the uxcore-side
  copy was left behind. The file is not imported anywhere, but the
  cleanup eliminates the duplicate-declaration risk if it ever becomes
  loaded. Keep body/html resets, scrollbar styles, dark-theme body
  rules, and the mobile background-size override.

- Fix stale-closure in modal Escape listeners. CustomModal.tsx and the
  uxcore Modal.tsx registered their keydown handlers in a useEffect
  whose handleClose captured the initial `closing` state, so a second
  Escape during the 180ms fade-out could re-enter the close cycle.
  Read handleClose through a ref so the `if (closing) return` guard
  always sees the current value.

- Merge the two split :global(body.darkTheme) .userProfile blocks in
  UserProfile.module.scss into one. No runtime change.

- Use classnames (`cn`) for the CountryBiasMap CyclingWord className
  instead of an inline template literal with a ternary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@MaryWylde MaryWylde merged commit 30996bf into dev May 15, 2026
1 of 2 checks passed
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