Files
Stirling-PDF/frontend/.storybook/preview.tsx
T
Reece Browne c2e8c3fa71 a11y: empty the grandfathered Storybook baseline (1,058 → 0) (#7309)
## What

Empties the light Storybook accessibility baseline — **1,058
grandfathered violations across 846 stories → 0** — so a new violation
fails the gate instead of being silently absorbed. Also burns the dark
baseline **812 → 56**; every entry left is one `main` already
grandfathers.

## The defect, repeated everywhere

A colour picked as a **fill**, chosen to carry a white label at 3:1,
reused as **text**, where the floor is 4.5:1. It recurred through status
accents, filled buttons, form labels, Mantine's light and outline
variants, CSS declarations, inline styles and the generated accent ramp.

Three systemic causes account for most of it:

- **Mantine's semantic slots were never bound.** `-text`, `-outline`,
`-light-color`, `-filled` and `-dimmed` all default to the hue's solid
fill. Both resolvers now pin them to the accessible ink for the active
scheme.
- **The tint ladder was compressed.** `--color-<hue>-50/100/200` pointed
at saturated 400-level primitives, so every "tint" background rendered
as a fill.
- **Text was faded with `opacity`**, pushing already-muted copy below
the floor. Each site now recedes via ink or surface, which is what
conveyed the state anyway.

## Dark mode

The colour resolver's dark half was empty, so dark fell through to
Mantine's stock palette — and fixing the naming violations unmasked the
contrast sitting underneath them. Both schemes now share one slot map,
since most slots are written in tokens that already flip.

The dark-only fixes: `--c-text-subtle` (3.0:1, used in 478 places), the
error and section-label inks, and the accent ramp's text step — which
light reaches by mixing toward black and dark has to reach by mixing
toward white.

## Also

- New `--c-*-solid` tokens for fills that must carry a white label,
distinct from the `--c-<tone>` values used for surfaces, borders and
icons.
- A `data-user-content-preview` opt-out for nodes rendering a facsimile
of the user's own document — WCAG governs the interface, not content
authored through it.

## Verification

- `task frontend:check:all` — green.
- Changed-set gate, both schemes, after the final rebase: **366 stories,
0 regressions**.
- Full sweep at the prior base — light **1,447 stories / 0 violations**,
dark **1,448 / 0 regressions**. The dark re-record was confirmed
key-by-key to be a strict subset of `main`'s, so nothing new is
grandfathered.

Roughly 28% of what this clears is naming and structure (`button-name`,
`label`, `aria-*`) and has no visual signature; the rest is contrast.
2026-08-12 09:06:05 +00:00

383 lines
14 KiB
TypeScript

/// <reference types="vite/client" />
// Storybook compiles .storybook/* with the classic JSX runtime, so the JSX in
// the decorators below transpiles to React.createElement and needs React in
// scope. (The app + story files use the automatic runtime via the portal vite
// config; this import is specifically for the preview config file.)
import React, { Suspense, useEffect } from "react";
import type { Decorator, Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";
import { MemoryRouter } from "react-router-dom";
import { withThemeByDataAttribute } from "@storybook/addon-themes";
// Reference React so the import isn't dropped as unused by the bundler — the
// classic runtime needs it present even though it's not named in the JSX.
void React;
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { SuiProvider } from "@portal/theme/SuiProvider";
import { MantineProvider } from "@mantine/core";
import {
mantineTheme as editorMantineTheme,
editorCssVariablesResolver,
} from "@core/theme/mantineTheme";
import { handlers } from "@portal/mocks/handlers";
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import { parse as parseToml } from "smol-toml";
import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
import "@mantine/core/styles.css";
import "@core/tokens/tokens.css";
import "@core/theme/index.css";
// The editor's semantic token layer (--bg-surface, --onboarding-title, …).
// The app reaches it through its style entry; without it here, components
// styled on those variables render unthemed (e.g. transparent modal surfaces)
// and axe measures contrast against colours the app never shows.
import "@core/styles/theme.css";
import "@core/tokens/base.css";
// Portal element reset + typography. Scoped to .portal-scope in the app so it
// can't leak into the editor; the decorator below adds that class around
// portal stories only, mirroring how PortalApp mounts.
import "@portal/theme/base.css";
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
// glob, so the toolbar language switcher can flip between all languages with no
// async fetch (Storybook has no backend to serve /locales/). t(key) then renders
// the shipped copy (e.g. "No sources connected yet") rather than the raw key.
const localeModules = import.meta.glob<string>(
"../editor/public/locales/*/translation.toml",
{ query: "?raw", import: "default", eager: true },
);
// Parse each locale into an i18next resources map. A malformed TOML degrades to
// an empty bundle for that one locale (its keys fall back to en-US) rather than
// taking the whole Storybook down.
const resources: Record<string, { translation: Record<string, unknown> }> = {};
for (const [path, raw] of Object.entries(localeModules)) {
const lng = path.match(/\/locales\/([^/]+)\/translation\.toml$/)?.[1];
if (!lng) continue;
let translation: Record<string, unknown>;
try {
translation = parseToml(raw) as Record<string, unknown>;
} catch {
translation = {};
}
resources[lng] = { translation };
}
if (!i18next.isInitialized) {
// initImmediate: false → initialise synchronously from the inline resources
// (there's no async backend here), so i18next is ready before the first story
// renders. Without it the first render can beat init and stick on raw keys.
void i18next.use(initReactI18next).init({
lng: "en-US",
fallbackLng: "en-US",
supportedLngs: Object.keys(resources),
resources,
interpolation: { escapeValue: false },
react: { useSuspense: false },
initImmediate: false,
});
} else {
// Something initialised i18next first (e.g. the app's async TOML backend):
// inject every shipped locale's copy so t() renders real copy, not raw keys,
// and the toolbar switcher can still change to any of them.
for (const [lng, bundle] of Object.entries(resources)) {
i18next.addResourceBundle(
lng,
"translation",
bundle.translation,
true,
true,
);
}
}
// Start MSW once. Storybook runs in a browser so this uses the service worker.
initialize({ onUnhandledRequest: "bypass" }, handlers);
// PortalApp wraps the app in a QueryClientProvider, so any component reaching a
// shared query hook throws "No QueryClient set" without one here. `retry: false`
// matches the portal test providers: a story showing an error state should show
// it immediately rather than sitting through backoff retries.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
// method, wallet) clear the session check and reach the MSW handlers instead of
// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see
// .storybook/main.ts), so ensureSaasSupabase() is a no-op and never replaces this
// client; only VITE_SAAS_API_URL (a mock origin MSW matches) is configured —
// injected via .storybook/main.ts's viteFinal define, not a frontend/.env file.
const saasStub = configureSupabase({
url: "http://saas.mock",
key: "storybook-anon-key",
authOptions: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
});
saasStub.auth.getSession = async () =>
({
data: { session: { access_token: "storybook-fake-jwt" } },
error: null,
}) as Awaited<ReturnType<typeof saasStub.auth.getSession>>;
/**
* Bridge between Storybook's `tier` global toolbar and the actual TierProvider.
* Without this the toolbar would just change a label; with it, every story
* that calls useTier() reflects the active toolbar value.
*/
function TierBridge({
tier,
children,
}: {
tier: Tier;
children: React.ReactNode;
}) {
return <TierProvider initialTier={tier}>{children}</TierProvider>;
}
/** Forces the TierProvider to re-mount whenever the toolbar tier changes. */
function TierKey({
tier,
children,
}: {
tier: Tier;
children: React.ReactNode;
}) {
return (
<TierBridge key={tier} tier={tier}>
{children}
</TierBridge>
);
}
/**
* Makes the Storybook toolbar the SINGLE source of truth for the theme.
*/
function ThemeBridge({
theme,
children,
}: {
theme: "light" | "dark";
children: React.ReactNode;
}) {
const { setTheme } = useTheme();
useEffect(() => {
setTheme(theme);
}, [theme, setTheme]);
return <>{children}</>;
}
/**
* Sets the theme attributes colors.css needs — always `data-app-theme="custom"`
* with the fixed default accent (data-accent="default"), matching the editor.
*/
function SchemeSetup({ scheme }: { scheme: "light" | "dark" }) {
useEffect(() => {
const root = document.documentElement;
root.setAttribute("data-app-theme", "custom");
root.setAttribute("data-accent", "default");
root.setAttribute("data-mantine-color-scheme", scheme);
}, [scheme]);
return null;
}
/** Switches i18next to the toolbar locale and keeps document dir/lang in sync. */
const withLocale: Decorator = (Story, context) => {
const locale = (context.globals.locale as string) ?? "en-US";
useEffect(() => {
void i18next.changeLanguage(locale);
document.documentElement.dir = rtlLanguages.includes(locale)
? "rtl"
: "ltr";
document.documentElement.lang = locale;
}, [locale]);
return <Story />;
};
/**
* Applies the Mantine theme the story's component actually runs under in the
* app: PortalApp wraps the Processor in SuiProvider, while the editor wraps
* everything else in its own ThemeProvider. Getting this wrong is not just
* cosmetic — the two themes carry different neutral ramps, so rendering an
* editor component under the Processor's theme drops it onto Mantine's stock
* greys and reports contrast failures the app doesn't have.
*/
function StoryTheme({
isPortalStory,
colorScheme,
children,
}: {
isPortalStory: boolean;
colorScheme: "light" | "dark";
children: React.ReactNode;
}) {
if (isPortalStory) {
return <SuiProvider colorScheme={colorScheme}>{children}</SuiProvider>;
}
return (
<MantineProvider
theme={editorMantineTheme}
cssVariablesResolver={editorCssVariablesResolver}
forceColorScheme={colorScheme}
>
{children}
</MantineProvider>
);
}
const withProviders: Decorator = (Story, context) => {
const tier = (context.globals.tier as Tier) ?? "pro";
const linkState =
(context.globals.linkState as LinkState) ?? "linked-subscribed";
// withThemeByDataAttribute exposes the toolbar theme as the `theme` global.
// Bind Mantine's color scheme to it so Mantine chrome (inputs, focus rings,
// default surfaces) follows the dark toggle alongside the SUI CSS variables.
// The global initialises to "" (before any toolbar interaction), so treat
// anything that isn't "dark" as light — matching the addon's own
// `selected || defaultTheme` fallback where defaultTheme is light.
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
// PortalApp mounts its views inside a .portal-scope wrapper, which is what
// the portal's base.css keys its reset/typography on. Give portal stories
// the same wrapper (and only them — the scoping exists precisely so portal
// styles never apply to editor components).
// `fileName` is only injected by the dev/build pipeline — under the Vitest
// runner it is absent, so path alone would silently drop every portal story
// onto the editor theme (where portal-only palette entries like `amber`
// resolve to nothing and render unstyled). The title prefix is the fallback
// that survives both environments.
const isPortalStory =
(context.parameters.fileName ?? "").includes("/portal/") ||
context.title.startsWith("Portal/");
return (
<MemoryRouter initialEntries={["/"]}>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<SchemeSetup scheme={colorScheme} />
<ThemeBridge theme={colorScheme}>
<StoryTheme isPortalStory={isPortalStory} colorScheme={colorScheme}>
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
from useLink() (matches App.tsx's nesting). */}
<LinkProvider key={linkState} initialState={linkState}>
<TierKey tier={tier}>
<UIProvider>
<Suspense fallback={null}>
{isPortalStory ? (
<div className="portal-scope">
<Story />
</div>
) : (
<Story />
)}
</Suspense>
</UIProvider>
</TierKey>
</LinkProvider>
</StoryTheme>
</ThemeBridge>
</ThemeProvider>
</QueryClientProvider>
</MemoryRouter>
);
};
const preview: Preview = {
loaders: [mswLoader],
// The scan runs once per theme (SCAN_THEME=light|dark, forwarded by
// .storybook/vitest.config.ts); pinning the global here themes every story in
// the run. Unset — the Storybook UI — falls back to the toolbar default.
initialGlobals: {
theme: import.meta.env.VITE_SCAN_THEME === "dark" ? "dark" : "light",
},
parameters: {
layout: "padded",
controls: {
matchers: { color: /(background|color)$/i, date: /Date$/i },
},
backgrounds: {
default: "app",
values: [
{ name: "app", value: "var(--c-bg)" },
{ name: "surface", value: "var(--c-surface)" },
],
},
a11y: {
// Run axe against the rendered story; `test: "error"` fails the scan on
// any violation. Context is left at the addon default (the document root)
// so it resolves under both the Storybook UI and the Vitest browser mount.
test: "error",
context: {
// Nodes carrying this attribute render a facsimile of the user's own
// document — their stamp text, their watermark, in the colour and
// opacity they chose. WCAG contrast governs the interface, not the
// content authored through it, and the controls that set those values
// are checked normally.
exclude: ["[data-user-content-preview]"],
},
},
},
globalTypes: {
tier: {
name: "Tier",
description: "Subscription tier — drives useTier() everywhere",
defaultValue: "pro",
toolbar: {
icon: "star",
items: [
{ value: "free", title: "Free" },
{ value: "pro", title: "Pay-as-you-go" },
{ value: "enterprise", title: "Enterprise" },
],
dynamicTitle: true,
},
},
linkState: {
name: "Link",
description: "Account-link state — drives useLink() everywhere",
defaultValue: "linked-subscribed",
toolbar: {
icon: "link",
items: [
{ value: "unlinked", title: "Unlinked" },
{ value: "linked-free", title: "Linked · Free" },
{ value: "linked-subscribed", title: "Linked · PAYG" },
],
dynamicTitle: true,
},
},
locale: {
name: "Locale",
description: "Active language — drives useTranslation() in all stories",
defaultValue: "en-US",
toolbar: {
icon: "globe",
items: Object.entries(supportedLanguages).map(([value, title]) => ({
value,
title: `${value} - ${title}`,
})),
dynamicTitle: true,
},
},
},
decorators: [
withLocale,
withProviders,
withThemeByDataAttribute({
themes: { light: "light", dark: "dark" },
defaultTheme: "light",
attributeName: "data-theme",
}),
],
};
export default preview;