mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Follow-up to #7009, which built the theme token layer (`primitives` → `colors` → `compat`). This PR moves the whole app onto it, removes hardcoded colours, turns on enforcement so they can't come back, and adds a user-selectable accent. ## What this does - **Semantic tokens everywhere** — legacy colour aliases and raw hex are rewritten to `--c-*` tokens (`--c-surface*`, `--c-text*`, `--c-primary`, …). Straight rename, no visual change. Genuine literals (brand/OAuth, colour pickers, data-viz) are left as-is. - **Fixes missing colours** — some tokens the migration referenced were never defined, so a few surfaces (login button, auth banners, badges, procurement view) silently lost their colour. All defined now, and they adapt to light/dark and the accent automatically. - **Blocking colour lint** — CI now fails on hardcoded colours, undefined tokens, or unreadable low-contrast status colours. - **User-selectable accent** — light and dark each get their own accent from Settings → Appearance, contrast-clamped so text stays legible. "Default" keeps the standard blue. ## Still to come Remaining inline-style hex, the legacy token-definition files (`theme.css`, `tokens.css`), and folding `zIndex.ts` onto the dimension tokens. ## Testing `task frontend:check:all` green; light/dark and accent switching spot-checked.
297 lines
9.9 KiB
TypeScript
297 lines
9.9 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 { 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 { 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";
|
|
import "@core/tokens/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);
|
|
|
|
// 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 />;
|
|
};
|
|
|
|
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";
|
|
return (
|
|
<MemoryRouter initialEntries={["/"]}>
|
|
<ThemeProvider>
|
|
<SchemeSetup scheme={colorScheme} />
|
|
<ThemeBridge theme={colorScheme}>
|
|
<SuiProvider 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}>
|
|
<Story />
|
|
</Suspense>
|
|
</UIProvider>
|
|
</TierKey>
|
|
</LinkProvider>
|
|
</SuiProvider>
|
|
</ThemeBridge>
|
|
</ThemeProvider>
|
|
</MemoryRouter>
|
|
);
|
|
};
|
|
|
|
const preview: Preview = {
|
|
loaders: [mswLoader],
|
|
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 automatically against the story root; violations show in the
|
|
// Accessibility panel. `context` replaced `element` in addon-a11y 9.x.
|
|
context: "#storybook-root",
|
|
config: {},
|
|
options: {},
|
|
test: "todo",
|
|
},
|
|
},
|
|
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;
|