mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
## 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.
383 lines
14 KiB
TypeScript
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;
|