From b3003dced01256d5ceda41e18c4f0a9696a1619d Mon Sep 17 00:00:00 2001 From: Mikolaj Szczepkowski Date: Mon, 2 Nov 2026 10:27:11 +0100 Subject: [PATCH 01/21] refactor(gallery): remove filters, search and count from hero - show every canvas tile unconditionally; drop URL-synced filter state - simplify hero to title and lead --- .../organisms/Gallery/Gallery.module.scss | 184 +----------------- src/components/organisms/Gallery/Gallery.tsx | 118 +---------- .../organisms/Gallery/GalleryFilter.tsx | 74 ------- 3 files changed, 8 insertions(+), 368 deletions(-) delete mode 100644 src/components/organisms/Gallery/GalleryFilter.tsx diff --git a/src/components/organisms/Gallery/Gallery.module.scss b/src/components/organisms/Gallery/Gallery.module.scss index 7eafe84..1109a2d 100644 --- a/src/components/organisms/Gallery/Gallery.module.scss +++ b/src/components/organisms/Gallery/Gallery.module.scss @@ -7,36 +7,16 @@ background-color: var(--color-bg); } -// Title + count + lead + filters: the Sources page's hero rhythm. +// Title + lead: the Publications page's hero. .title { @include font-size(60px, 0.95); - display: flex; - gap: rem(12); - align-items: flex-start; margin-bottom: rem(30); font-family: var(--font-outfit); font-weight: 800; letter-spacing: -0.04em; } -.count { - @include flex(row, center, center); - @include font-size(14px, 1); - - flex-shrink: 0; - width: rem(37); - height: rem(22); - margin-top: rem(4); - font-family: var(--font-dm-mono); - font-weight: 300; - color: var(--color-charcoal); - text-align: center; - letter-spacing: -0.02em; - background-color: var(--color-media-bg); - border-radius: rem(4); -} - .lead { @include font-size(19px, 1.3); @@ -47,136 +27,6 @@ letter-spacing: -0.01em; } -.filtersLabel { - @include font-size(16px, 1.3); - - margin-top: rem(38); - margin-bottom: rem(24); - font-family: var(--font-dm-mono); - font-weight: 300; - color: var(--color-media-bg); - letter-spacing: -0.01em; -} - -.filters { - @include flex(row, flex-start, center); - - gap: rem(6); - flex-wrap: wrap; -} - -// --- Filter pills ----------------------------------------------------------- - -.filterPill { - @include flex(row, center, center); - @include font-size(12px, 1); - - gap: rem(6); - padding: rem(8) rem(12); - font-family: var(--font-dm-mono); - font-weight: 400; - color: var(--color-charcoal); - letter-spacing: -0.02em; - cursor: pointer; - background-color: var(--color-media-bg); - border: 0; - border-radius: rem(4); - transition: background-color 0.25s ease; - - &:hover { - background-color: var(--color-popup); - } -} - -.filterPillActive { - background-color: var(--color-yellow-800); -} - -.filterChevron { - width: rem(12); - height: rem(12); -} - -// The option count: a tiny dark square INSIDE the pill, rem(2) off its top -// and right edges, taking its place in the row (the pill's right padding is -// the offset). -.filterCount { - @include flex(row, center, center); - @include font-size(11px, 1); - - align-self: flex-start; - - // rem(2) off the pill's top and right edges: the pill pads rem(8)/rem(12). - margin: rem(-6) rem(-10) 0 0; - min-width: rem(14); - height: rem(14); - padding-inline: rem(3); - font-family: var(--font-dm-mono); - font-weight: 400; - color: var(--color-bg); - text-align: center; - letter-spacing: -0.02em; - background-color: var(--color-gray-2800); - border-radius: rem(3); -} - -// --- Search (the hero's bottom-right aside) --------------------------------- - -.search { - @include flex(row, flex-start, center); - - gap: rem(8); - width: rem(220); - height: rem(34); - padding-inline: rem(10); - color: var(--color-white); - background-color: var(--color-charcoal); - - // Design's one-off border gray (no token holds it). - border: 1px solid #646464; - border-radius: rem(4); - transition: border-color 0.25s ease; - - &:focus-within { - border-color: var(--color-stone); - } -} - -.searchIcon { - flex-shrink: 0; - width: rem(14); - height: rem(14); - color: var(--color-gray-1600); -} - -.searchInput { - @include font-size(12px, 1.1); - - width: 100%; - font-family: var(--font-outfit); - font-weight: 500; - color: var(--color-white); - background: none; - border: 0; - outline: none; - - &::placeholder { - color: var(--color-white); - } - - // No native clear button — the field is quiet, backspace clears it. - &::-webkit-search-cancel-button { - display: none; - appearance: none; - } -} - -// The in-box copy (under the filters) exists for the phone only; the aside -// copy covers desktop and tablet. -.searchInBox { - display: none; -} - // --- Tiles ------------------------------------------------------------------ // Three to a row with visible gaps (the approved v2 design breathes). @@ -256,16 +106,6 @@ letter-spacing: -0.01em; } -.empty { - @include font-size(16px, 1.3); - - grid-column: 1 / -1; - padding: rem(60) rem(20); - font-family: var(--font-outfit); - font-weight: 300; - color: var(--color-text-muted); - text-align: center; -} // --- The cover: the canvas miniature (design handoff PNG) ------------------- @@ -297,7 +137,6 @@ opacity 0.6s ease; } - // Below desktop the tiles ride the page gutter and the desktop gaps // (80/50 on a 768/375 rem base) would read as chasms — they tighten per // breakpoint. @@ -319,14 +158,6 @@ @include font-size(40px, 1.1); } - .count { - margin-top: rem(4); - } - - .filtersLabel { - margin-top: rem(30); - } - .grid { grid-template-columns: 1fr; gap: rem(20); @@ -338,17 +169,4 @@ .cardText { max-width: none; } - - // The search swaps spots: full-bleed under the filters, the aside copy - // gone. - .searchAside { - display: none; - } - - .searchInBox { - @include flex(row, flex-start, center); - - width: 100%; - margin-top: rem(20); - } } diff --git a/src/components/organisms/Gallery/Gallery.tsx b/src/components/organisms/Gallery/Gallery.tsx index edad29b..d40ff2e 100644 --- a/src/components/organisms/Gallery/Gallery.tsx +++ b/src/components/organisms/Gallery/Gallery.tsx @@ -1,42 +1,17 @@ 'use client'; -import classNames from 'classnames'; import Image from 'next/image'; import Link from 'next/link'; import styles from './Gallery.module.scss'; -import GalleryFilter from './GalleryFilter'; -import { - GALLERY_COVERS, - GALLERY_ITEMS, - GALLERY_REGIONS, - GALLERY_SOURCES, - GALLERY_TYPES, - type GalleryItem, - galleryHref, -} from './galleryData'; +import { GALLERY_COVERS, GALLERY_ITEMS, type GalleryItem, galleryHref } from './galleryData'; import PageFade from '@/components/molecules/PageFade/PageFade'; import PageHero from '@/components/molecules/PageHero/PageHero'; import Button from '@/components/atoms/Button/Button'; -import Icon from '@/components/atoms/Icon/Icon'; import { revealIndex, useReveal } from '@/hooks/useReveal'; -import { useUrlParam } from '@/hooks/useUrlParam'; import { track } from '@/lib/events/client'; -// Selections live in the URL (?types=stats,charts®ions=ssa&q=debt) so a -// filtered view is shareable and survives a reload — the Sources page's -// convention. Unknown ids are dropped on parse. -const idsParser = (valid: Set) => (raw: string | null) => - (raw ?? '').split(',').filter((id) => valid.has(id)); - -const parseTypes = idsParser(new Set(GALLERY_TYPES.map((t) => t.id))); -const parseRegions = idsParser(new Set(GALLERY_REGIONS.map((r) => r.id))); -const parseSources = idsParser(new Set(GALLERY_SOURCES.map((s) => s.id))); -const serializeIds = (ids: string[]) => (ids.length ? ids.join(',') : null); -const parseQuery = (raw: string | null) => raw ?? ''; -const serializeQuery = (value: string) => (value.trim() ? value : null); - // Records `gallery.open` when a visitor activates a tile's link. `click` covers // a left-click, a modifier-click and Enter on the focused link; a middle-click // fires `auxclick` instead, so that one is listened for too. Prefetch and @@ -54,103 +29,27 @@ const openEvents = (item: GalleryItem) => { /** * Insights → Gallery: expert-curated Studio canvases, ready to monitor - * as-is or to fork. The dark hero (title, count, lead, the three filter - * dropdowns, the search box), then the canvas tiles on the light ground, - * three to a row — each cover a miniature of the canvas it stands for. - * An item matches a region or source filter when any of its regions or - * sources is selected. + * as-is or to fork. The dark hero (title, lead), then every canvas + * tile on the light ground, three to a row — each cover a miniature of the + * canvas it stands for. */ const Gallery = () => { const sectionRef = useReveal(); - const [types, setTypes] = useUrlParam('types', parseTypes, serializeIds); - const [regions, setRegions] = useUrlParam('regions', parseRegions, serializeIds); - const [sources, setSources] = useUrlParam('sources', parseSources, serializeIds); - const [query, setQuery] = useUrlParam('q', parseQuery, serializeQuery); - - const visible = GALLERY_ITEMS.filter((item) => { - if (types.length && !item.types.some((type) => types.includes(type))) { - return false; - } - - if (regions.length && !item.regions.some((region) => regions.includes(region))) { - return false; - } - - if (sources.length && !item.sources.some((source) => sources.includes(source))) { - return false; - } - - const needle = query.trim().toLowerCase(); - - return ( - !needle || - item.title.toLowerCase().includes(needle) || - item.description.toLowerCase().includes(needle) - ); - }); - - // The search field renders twice: as the hero's aside (desktop - // bottom-right, above the box on tablet) and inside the box under the - // filters — the phone spot. CSS shows exactly one; both bind the same - // state, so switching breakpoints keeps the typed query. - const searchField = (extraClass: string) => ( - - ); return (
- +

Gallery - - {String(GALLERY_ITEMS.length).padStart(2, '0')} -

Expert-curated canvases built in DFO Studio with live knowledge graph data — ready to monitor as-is or to use as a starting point to make it your own.

-

- Filters -

-
- - - -
- {searchField(styles.searchInBox)}
-
    - {visible.map((item, index) => { +
      + {GALLERY_ITEMS.map((item, index) => { const { action, ...cover } = GALLERY_COVERS[item.kind]; return ( @@ -205,9 +104,6 @@ const Gallery = () => { ); })} - {visible.length === 0 && ( -
    • No canvases match — clear a filter or two.
    • - )}
    diff --git a/src/components/organisms/Gallery/GalleryFilter.tsx b/src/components/organisms/Gallery/GalleryFilter.tsx deleted file mode 100644 index 78c7f97..0000000 --- a/src/components/organisms/Gallery/GalleryFilter.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'use client'; - -import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; -import classNames from 'classnames'; - -import styles from './Gallery.module.scss'; - -import Icon from '@/components/atoms/Icon/Icon'; -import searchStyles from '@/components/organisms/DataSearch/DataSearch.module.scss'; -import { OptionRow } from '@/components/organisms/DataSearch/SlotMenu'; - -type Option = { readonly id: string; readonly label: string }; - -type GalleryFilterProps = { - label: string; - options: readonly Option[]; - selected: readonly string[]; - onChange: (next: string[]) => void; -}; - -/** - * One filter pill (Types / Regions / Sources) opening the house dropdown — - * the same panel + OptionRow rows the search page's pickers use. Multi- - * select, empty selection = everything; rows toggle without closing the - * menu (OptionRow is a plain button, not a menu item). The corner count - * shows how many options are SELECTED — absent while the filter is off. - */ -const GalleryFilter = ({ label, options, selected, onChange }: GalleryFilterProps) => { - const toggle = (id: string) => - onChange(selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id]); - - return ( - - - - - - -
    - {options.map((option) => ( - toggle(option.id)} - > - {option.label} - - ))} -
    -
    -
    -
    - ); -}; - -export default GalleryFilter; From 201cbe4bdc9e18173e37b7f406a280fdf97c0c19 Mon Sep 17 00:00:00 2001 From: Mikolaj Szczepkowski Date: Mon, 2 Nov 2026 15:24:50 +0100 Subject: [PATCH 02/21] feat(a11y): respect prefers-reduced-motion across animations - Hero and dataset nets draw a still frame instead of animating - Spinners slow down rather than stop; pulses and pings are disabled - Nets restart when the motion preference changes --- .../atoms/Spinner/Spinner.module.scss | 7 ++++++ .../DataSearch/DataSearch.module.scss | 6 +++++ .../DownloadDataset/DownloadDataset.tsx | 6 ++++- .../organisms/DownloadDataset/dataset-net.ts | 22 +++++++++++++++++-- src/components/organisms/Hero/Hero.tsx | 6 ++++- src/components/organisms/Hero/hero-net.ts | 18 +++++++++++++-- .../Studio/workspace/ActivityLog.module.scss | 11 ++++++++++ .../Studio/workspace/PlanLedger.module.scss | 7 ++++++ 8 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/components/atoms/Spinner/Spinner.module.scss b/src/components/atoms/Spinner/Spinner.module.scss index da5f949..d77dcec 100644 --- a/src/components/atoms/Spinner/Spinner.module.scss +++ b/src/components/atoms/Spinner/Spinner.module.scss @@ -17,3 +17,10 @@ rotate: 360deg; } } + +@media (prefers-reduced-motion: reduce) { + // A frozen ring reads as a broken circle, so the spinner stays on, slowed. + .spinner { + animation-duration: 2.4s; + } +} diff --git a/src/components/organisms/DataSearch/DataSearch.module.scss b/src/components/organisms/DataSearch/DataSearch.module.scss index d85ab5a..40fb9b4 100644 --- a/src/components/organisms/DataSearch/DataSearch.module.scss +++ b/src/components/organisms/DataSearch/DataSearch.module.scss @@ -490,6 +490,12 @@ $table-head-cover: rem(48); } } +@media (prefers-reduced-motion: reduce) { + .slotUpdating { + animation: none; + } +} + // Sentence slots: yellow, underlined, clickable. The underline and the // hover fill are two stacked BACKGROUND layers (not a pseudo-element): a // slot can be an inline span that wraps across lines, and with diff --git a/src/components/organisms/DownloadDataset/DownloadDataset.tsx b/src/components/organisms/DownloadDataset/DownloadDataset.tsx index 05f12b0..be020b5 100644 --- a/src/components/organisms/DownloadDataset/DownloadDataset.tsx +++ b/src/components/organisms/DownloadDataset/DownloadDataset.tsx @@ -10,6 +10,7 @@ import Button from '@/components/atoms/Button/Button'; import DataPrompt from '@/components/molecules/DataPrompt/DataPrompt'; import SectionHeading from '@/components/molecules/SectionHeading/SectionHeading'; import StatTiles from '@/components/molecules/StatTiles/StatTiles'; +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; import { revealIndex, useReveal } from '@/hooks/useReveal'; import { catalogueChips, @@ -27,14 +28,17 @@ const DownloadDataset = ({ catalogue }: { catalogue: SearchCatalogue | null }) = const sectionRef = useReveal(); const canvasRef = useRef(null); const tiles = catalogueTiles(catalogue); + const reducedMotion = usePrefersReducedMotion(); + // The net reads the motion preference itself when it starts (a still + // frame, or the live field); it restarts whenever the reader flips it. useEffect(() => { if (!sectionRef.current || !canvasRef.current) { return; } return startDatasetNet(sectionRef.current, canvasRef.current); - }, [sectionRef]); + }, [sectionRef, reducedMotion]); return (
    { + resize(); + + // No loop runs under reduced motion, so the still frame is redrawn + // by hand whenever the section changes size. + if (reducedMotion) { + draw(); + } + }); resizeObserver.observe(innerEl); @@ -403,7 +415,13 @@ export function startDatasetNet( frame = requestAnimationFrame(loop); } - frame = requestAnimationFrame(loop); + // Under reduced motion the field is one still frame: the squares sit at + // their home spots and nothing drifts. + if (reducedMotion) { + draw(); + } else { + frame = requestAnimationFrame(loop); + } return () => { cancelAnimationFrame(frame); diff --git a/src/components/organisms/Hero/Hero.tsx b/src/components/organisms/Hero/Hero.tsx index b7d535c..721685d 100644 --- a/src/components/organisms/Hero/Hero.tsx +++ b/src/components/organisms/Hero/Hero.tsx @@ -7,6 +7,7 @@ import styles from './Hero.module.scss'; import { startHeroNet } from './hero-net'; import Icon from '@/components/atoms/Icon/Icon'; +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; import { revealIndex, useReveal } from '@/hooks/useReveal'; import LogoSignature from '@/assets/svg/logo-signature.svg'; import WorldMap from '@/components/atoms/WorldMap/WorldMap'; @@ -53,6 +54,7 @@ const Hero = ({ const sectionRef = useReveal(0); const innerRef = useRef(null); const canvasRef = useRef(null); + const reducedMotion = usePrefersReducedMotion(); // One-shot arrow swap (mirrors Button): hover must rest a beat to start // it, and once started it always plays out — leaving doesn't cut it off. @@ -114,8 +116,10 @@ const Hero = ({ observer.disconnect(); net.stop(); }; + // The net reads the motion preference itself when it starts (a still + // frame, or the live field); it restarts whenever the reader flips it. // eslint-disable-next-line react-hooks/exhaustive-deps -- sectionRef is stable (useReveal's ref) - }, []); + }, [reducedMotion]); // One reveal GROUP for the whole hero: the observer watches the // full-viewport section (the telescoping panels start at scale 0 — diff --git a/src/components/organisms/Hero/hero-net.ts b/src/components/organisms/Hero/hero-net.ts index ffb5ba1..98ccb6f 100644 --- a/src/components/organisms/Hero/hero-net.ts +++ b/src/components/organisms/Hero/hero-net.ts @@ -283,7 +283,15 @@ export function startHeroNet( }); } - const resizeObserver = new ResizeObserver(resize); + const resizeObserver = new ResizeObserver(() => { + resize(); + + // No loop runs under reduced motion, so the still frame is redrawn + // by hand whenever the panel changes size. + if (reducedMotion) { + draw(); + } + }); resizeObserver.observe(innerEl); function update(now: number): void { @@ -405,7 +413,13 @@ export function startHeroNet( frame = requestAnimationFrame(loop); } - frame = requestAnimationFrame(loop); + // Under reduced motion the field is one still frame: the squares sit at + // their home spots and nothing drifts, swirls or pours in. + if (reducedMotion) { + draw(); + } else { + frame = requestAnimationFrame(loop); + } return { stop: () => { diff --git a/src/components/organisms/Studio/workspace/ActivityLog.module.scss b/src/components/organisms/Studio/workspace/ActivityLog.module.scss index c2b2bad..1e03418 100644 --- a/src/components/organisms/Studio/workspace/ActivityLog.module.scss +++ b/src/components/organisms/Studio/workspace/ActivityLog.module.scss @@ -476,3 +476,14 @@ opacity: 0.6; } } + +@media (prefers-reduced-motion: reduce) { + // The live dot sits still: no ping ring, no pulse on the running step. + .live::after { + display: none; + } + + .stepRunning::before { + animation: none; + } +} diff --git a/src/components/organisms/Studio/workspace/PlanLedger.module.scss b/src/components/organisms/Studio/workspace/PlanLedger.module.scss index 57fc685..22af46a 100644 --- a/src/components/organisms/Studio/workspace/PlanLedger.module.scss +++ b/src/components/organisms/Studio/workspace/PlanLedger.module.scss @@ -242,6 +242,13 @@ } } +@media (prefers-reduced-motion: reduce) { + // A frozen ring reads as a broken circle, so the spinner stays on, slowed. + .spin { + animation-duration: 2.4s; + } +} + // The notice's live region, mounted empty, spanning the row. .live { grid-column: 1 / -1; From 60ae56b7b196d03dd2301fcb2b6f899a9eb45337 Mon Sep 17 00:00:00 2001 From: Mikolaj Szczepkowski Date: Mon, 2 Nov 2026 15:35:46 +0100 Subject: [PATCH 03/21] fix(a11y): make decorative showcase cards inert --- src/components/organisms/CreateCanvas/ShowcaseCards.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/organisms/CreateCanvas/ShowcaseCards.tsx b/src/components/organisms/CreateCanvas/ShowcaseCards.tsx index 0dd664d..a5fee98 100644 --- a/src/components/organisms/CreateCanvas/ShowcaseCards.tsx +++ b/src/components/organisms/CreateCanvas/ShowcaseCards.tsx @@ -240,8 +240,11 @@ const ShowcaseCards = ({ grid }: { grid: Grid }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [reducedMotion, phone]); + // Pure decoration: the cards keep their real buttons (the source line, + // the left-out line), so `inert` takes the whole layer out of the tab + // order and hit testing; aria-hidden covers readers on older browsers. return ( -