Compare commits

...
Author SHA1 Message Date
Reece 2a0556a8d3 chore(storybook): list obsolete areas by name
A bare regex hid both what was excluded and why. A named list makes the
next obsolete area one line, and puts the reason where someone reading
the report will look.
2026-08-08 00:26:26 +01:00
Reece 1659aaba67 chore(storybook): stop reporting watched folders as a coverage gap
The feature is hidden and obsolete and will not be unhidden, so its
components are not surfaces anyone should write stories for. Excluded
rather than left in the report, where they read as outstanding work.
2026-08-08 00:26:26 +01:00
Reece de9c6f9d41 test(proprietary): cover the spend cap, policy banner and login navigation link
The spend cap is driven by real state — presets, a custom amount and
clearing back to no cap — plus the cases where the document estimate
cannot be drawn. Which run the policy banner picks out of the list is its
whole behaviour, so each in-flight status gets a story.
2026-08-08 00:26:25 +01:00
Reece 4268c98094 fix(a11y): name every progress bar
A progressbar with values but no name announces a bare percentage — the
heading beside it is visual context a screen reader does not get from the
node. Eight were unnamed; each takes the wording already shown next to
it, reusing the existing key where one exists rather than inventing new
copy for the same string.

Verified per instance rather than by proximity: an earlier check looked at
a fixed window after the first <Progress> in each file, which would
mis-judge files holding several.
2026-08-08 00:26:25 +01:00
Reece 37cba3051b test(proprietary): cover the usage meter and the policy-enforcement overlay
MeterBar's three states are the reason it exists, so each gets a story
rather than leaving the bar's fill to carry the meaning alone. The
overlay covers each progress case and the accent tones a policy can
carry.
2026-08-08 00:26:25 +01:00
Reece 831213d56b test(core): type the hotkey story fixture instead of casting to never
The context value type was unexported, so the fixture reached for `as
never` — which switches off checking entirely, meaning a change to the
context shape would not surface here. Exporting the type lets the fixture
be checked against the real thing.
2026-08-08 00:26:16 +01:00
Reece 550e447b21 test(onboarding): extend the existing shell stories, and expose the coverage report as a task
The shell already had stories and I replaced them wholesale, dropping
Default, SteppedWithBack and NotDismissible — and introducing a
NotDismissable spelling alongside main's NotDismissible. Story ids are
URLs, so each removal broke every link to it. Restored main's version and
layered the new states on top.

storybook-coverage.mjs was committed but referenced by nothing, so it was
undiscoverable; it now has a task alongside the other storybook ones.
2026-08-07 23:27:30 +01:00
Reece 3aad50ac99 chore(storybook): don't count API bridges as surfaces
The viewer's *APIBridge components register an API into the viewer
context and render null — wiring, like the providers and gates already
excluded. Counting them made the viewer look worse covered than it is
and would have had someone writing stories that show a blank frame.
2026-08-07 23:26:12 +01:00
Reece 3d023cba42 chore(storybook): note that props-only does not mean cheap
The classifier answers 'does this need a provider', not 'is this quick'.
ButtonAppearanceOverlay wants real PDF bytes and AppConfigModalLazy pulls
in the entire settings tree; both classify as props-only.
2026-08-07 23:26:12 +01:00
Reece d41078a74c test(shared): cover the sidebar brand header
Both rail widths, since the mark swaps between icon-only and icon-plus-
wordmark as the sidebar collapses.
2026-08-07 23:26:12 +01:00
Reece 0631e3d619 chore(storybook): record the alias blocker across all four flavours
Not just desktop — saas, cloud and prototypes hit the same wall. Storybook
resolves @app/* through the proprietary vite tsconfig, so any flavour file
importing an @app/* asset that lives only in its own tree fails to resolve
and the story file never loads. That is what the 0% areas are, and the fix
is a build-config decision with blast radius across every existing story.
2026-08-07 23:26:12 +01:00
Reece 68d2a96b71 chore(storybook): record why desktop/ cannot be storied yet
Storybook resolves @app/* through the proprietary vite tsconfig, but the
desktop flavour resolves it desktop-first, and 34 desktop files import
@app/* assets that exist only under desktop/. Those imports do not
resolve in Storybook, so a desktop story file fails to load outright —
worth knowing before someone spends an afternoon on it.
2026-08-07 23:26:12 +01:00
Reece 3d83f0cddd chore(storybook): report coverage by surface, and by what each gap needs
Counting a component covered by an adjacent .stories.tsx is wrong here —
MantineForms covers Select, MultiSelect, NumberInput and ColorInput
between them, and a sibling check reports those as gaps and invites
duplicate stories. Coverage is counted by import instead.

The useful part is the classification. A gap needing only props is
minutes of work; one needing a context slice or MSW handlers is not, and
the two should not sit in the same queue. Contexts the Storybook harness
already mounts do not count against a component, and modules that return
a config object rather than markup are not surfaces at all.
2026-08-07 23:26:12 +01:00
Reece 98afa1dbc6 fix(a11y): make the popover trigger the button, not a wrapper div
Popover.Target applies aria-haspopup and aria-expanded to whatever it
wraps. Wrapping a plain div put aria-expanded somewhere it is not a
permitted attribute, so the disclosure state was invalid markup rather
than something assistive tech could use. The ActionIcon is the target now.

Caught by the new PageSelectByNumberButton story; the viewer's search
button had the same shape.
2026-08-07 23:26:12 +01:00
Reece d7df847495 test(onboarding): cover the slide shell
The frame every tour slide sits in: hero panel, copy, step position and
the action row — including the not-dismissable and disabled-action states,
which decide whether a user can get past a step.
2026-08-07 23:26:12 +01:00
Reece 2c9effffd1 test(core): cover the hotkey display and bulk page selection
The Advanced toggle carried its name only in a title attribute, which is
unreliable for screen readers and invisible to sighted keyboard users; it
takes an aria-label now. Caught by the new panel story.

HotkeyProvider pulls in the whole tool-workflow chain, but the display
reads one function off the context — so the context is exported and the
story supplies that slice, using the real formatter so the caps render as
they do in the app.
2026-08-07 23:26:12 +01:00
Reece 6d7e22f8de test(core): extend the existing stories instead of replacing them
Both files already had stories and I rewrote them wholesale, which
deleted ResultsPreview's Default and Loading and swapped
HoverActionMenu's typed HoverAction fixtures for ad-hoc icon imports.
Story ids are URLs, so removing a name breaks every link to it.

Restored both to what main has and layered the extra states on top.
2026-08-07 23:25:48 +01:00
Reece 65ba92bbff test(viewer): cover the thumbnail sidebar, and fix its current-page label
The sidebar reads the viewer context for page counts and thumbnail
rendering. Rather than standing up a ViewerProvider and the document
pipeline behind it, the stories supply the small slice of context the
sidebar actually touches — enough to exercise page counts, the selected
page and the empty state.

The story immediately caught the current page's label sitting at 2.91:1:
it used the primary fill on the primary tint. It takes the accent text
step now, like the other accent copy.
2026-08-07 21:03:08 +01:00
Reece 64e96434d8 fix(a11y): make viewer page thumbnails keyboard-operable
Each thumbnail jumps the viewer to that page, but was a Box with an
onClick — so page navigation from the sidebar was mouse-only. They are
controls now, and aria-current marks the page being viewed, which the
highlight alone only conveyed visually.
2026-08-07 21:03:08 +01:00
Reece 3b2a92f4b3 fix(a11y): let Escape deselect in the PDF text editor
Clicking the page backdrop clears the current selection, but that path is
mouse-only — a keyboard user had no way out of a selection at all. The
backdrop stays a plain surface rather than becoming a control; it is a
canvas, not a button.
2026-08-07 21:03:08 +01:00
Reece 20acfbf0b6 fix(a11y): give clickable thumbnails, icon tiles and a pseudo-link real semantics
Three more click targets with no keyboard path.

DocumentThumbnail repeats its onClick across four render branches, so the
semantics are built once and only when a handler was actually given — a
decorative thumbnail should not take a tab stop.

The OCR setup-guide text was styled as a link and opened one, but was a
Text with an onClick: unreachable by keyboard and no target cue on hover.
It is now an anchor.

IconSelector's tiles are choices, so they say so, and each carries the
label its tooltip already showed.
2026-08-07 21:03:08 +01:00
Reece cbcfaf3e9d fix(a11y): make the plan and billing-period choices keyboard-operable
Two shapes of the same defect in checkout.

The plan cards already had a "Select Monthly" / "Select Yearly" button,
but it carried no handler — the click lived on the Paper around it, which
is mouse-only. Pressing Enter on the focused button did nothing. The
button is the natural control, so it now holds the handler and the card
stays a mouse convenience.

The billing-period cards in StaticCheckoutModal have no inner button at
all, so there the card really is the control and takes the semantics to
match, via a helper beside the style one it already used.
2026-08-07 21:03:08 +01:00
Reece 34595fa63a fix(a11y): let the file checkbox actually select the file
FileListItem's checkbox had onChange={() => {}} with a comment deferring
to the row's onClick — but that handler is mouse-only, so pressing Space
on the focused checkbox did nothing and file selection was unreachable by
keyboard. The checkbox now carries the keyboard path, and its click stops
at the wrapper so a mouse press doesn't toggle twice.
2026-08-07 21:03:08 +01:00
Reece d0054ae67b fix(a11y): make choice cards operable by keyboard
CardSelector's options were Cards with an onClick and nothing else — no
role, no tab stop, no key handler — so the split method (and every other
card choice built on it) could not be reached or chosen without a mouse.
They are controls, so they now say so, and a disabled option is marked
aria-disabled rather than merely faded.

Found by the new story: the component had none, so it had never been
scanned.
2026-08-07 21:03:08 +01:00
Reece 258ea55657 test(shared): cover the brand marks and chrome controls
Wordmark, LogoIcon, SidebarToggleIcon and the app-switch menu items —
four small components that share the app's top-left corner, so they are
grouped in one file rather than four near-empty ones.

AppSwitchMenuItems is a fragment of dropdown items, not a standalone
menu, so the story mounts it in the Dropdown its callers use.
2026-08-07 21:03:08 +01:00
Reece e81c2e819b fix(a11y): make the default accent foreground legible, and cover Badge
--c-accent-fg is the accent tuned as a foreground — it is used as a text
colour in fourteen places. Custom themes overwrite it from
--user-accent-fg, which is already forced dark enough on light bases, but
the default build left it on the raw fill at 3.67:1.

Caught by the new Badge story: none of its consumers had one, so the
token had never been scanned.
2026-08-07 21:03:08 +01:00
Reece c66b92b397 test(storybook): mount the providers Tooltip needs, and cover OperationButton
Tooltip reads the user's logo preference and the sidebar geometry it
positions against, so any story rendering one threw. It is used by
around a hundred components, which made this a harness gap rather than a
per-story one — both providers now sit alongside the others in preview,
matching how the real app always mounts them.

OperationButton is the run control every tool panel ends with; its
disabled states are the interesting ones, because each explains what the
user has to fix.
2026-08-07 21:03:08 +01:00
Reece 940fa5a25a test(tools): cover the results preview
Paged output preview: single and multiple results, thumbnails still
rendering, and the empty states.
2026-08-07 21:03:08 +01:00
Reece 8819c2437c i18n(en-US): name the auto-rotate per-page results region
The report's scroll region gained an accessible name; the key it uses
needs to exist in the locale.
2026-08-07 21:03:08 +01:00
Reece 93971df7a2 test(tools): cover the page-number and stamp automation settings
The pipeline-builder form of both panels: a plain parameters object with
a change callback, rather than the tool's own hook.
2026-08-07 21:03:07 +01:00
Reece 4af12f064a test(tools): cover auto-rotate settings, report and the workflow title
Adds a shared story helper for tool settings panels. Most take a whole
parameters hook rather than a plain object, so a story needs the hook's
shape with state that actually updates when the panel writes to it —
otherwise every field freezes on a static snapshot.

The report's per-page list scrolls but holds no control of its own, so it
was unreachable without a mouse; it now takes a tab stop, matching the
file picker's scroll region.
2026-08-07 21:03:07 +01:00
Reece 30c3f92a42 fix(a11y): drive the Storybook contrast baseline to zero
Clears the last 159 grandfathered violations, so the baseline is now
empty and any new violation fails the gate outright.

Three root causes accounted for nearly all of them:

- Mantine's semantic slots were never bound. Its -text, -outline,
  -light-color, -filled and -dimmed variables default to each hue's
  solid fill, which is chosen to carry white and is far too light to
  read as ink. Both theme resolvers now pin them to the -dark tokens.
  The portal theme had no such bindings at all, and its primaryColor
  tuple referenced --color-blue-*, which was never defined — so the
  portal silently fell back to stock Mantine blue. Those tokens now
  exist alongside the other SUI hues.

- The legacy --color-<hue>-50/100/200 rungs all pointed at saturated
  400-level primitives, so every "tint" background rendered as a fill.
  Real tints now back those rungs, which also repairs Mantine's [0..2]
  light-variant slots.

- Text faded with `opacity` rather than a muted colour. Seven sites
  dropped below the floor that way; each now recedes via ink or
  surface, which is what conveyed the state anyway.

Also names the icon-only assistant button, the ColorInput eye-dropper
and labelless loading buttons, and marks the disabled file-picker
trigger aria-disabled so axe stops treating it as an active control.

Adds --c-*-solid tokens for fills that must carry a white label, and a
data-user-content-preview opt-out for nodes that render a facsimile of
the user's own document in colours they chose.

Verified: 1,417 story renders, 0 violations.
2026-08-07 21:02:14 +01:00
Reece 9f71d56d5a fix(a11y): spend-cap chip accent text 2026-08-07 21:02:14 +01:00
Reece 06e421d789 fix(a11y): remaining fill-as-text colours across CSS, inline styles and tokens
Sweeps the sources the earlier token work didn't reach: Mantine fill
variables used as text in stylesheets, inline style objects setting
color to an accent or status fill, base --color-* tokens (rather than
their -dark text counterparts), the primary light-variant text
variable, active tab labels, and two portal rules.
2026-08-07 21:02:13 +01:00
Reece 5ea0720e13 chore(a11y): keep the triage helper with the other scan scripts
.storybook/ is where the scan and gate scripts live, and where eslint
expects Node globals.
2026-08-07 21:02:13 +01:00
Reece 1af43768af fix(a11y): accent and status colours used as text in component CSS
The same fill-as-text substitution the tokens already fixed, now in the
75 component stylesheet declarations that reached past them: a bare
color: var(--c-primary) (or --c-danger/--c-success/--c-warning) reads
the fill value, which sits between 2.2:1 and 3.5:1 on the page. Each
now takes the matching text token; background, border and outline
declarations are untouched.

Also deepens the two code-palette greys, which are read on the code
header ground rather than on white — the muted one was at 2.6:1.
2026-08-07 21:02:13 +01:00
Reece 79c41169d5 fix(a11y): warning accent reads white-on-amber like its siblings
The filled warning kept its light amber and took dark ink, which was
the odd one out — every other filled accent is white on colour, and the
dark-on-amber pairing reads worse in practice than the contrast figure
suggests. Deepening the fill to amber-700 puts the white label at
5.02:1, matching success exactly and danger closely.

Also nudges the subtle-text grey: it was tuned to clear 4.5:1 on the
page canvas, but banners tint their background darker and it landed at
4.39:1 there.
2026-08-07 21:01:17 +01:00
Reece 35586c224f fix(a11y): accent ramp's text shade, link colour, and remaining tinted badges
The generated accent ramp mixed shade 600 to 88% of the base hue, which
lands at 4.2:1 on the page — and Mantine maps shade 6 to the link colour
and the tuple's default text, so every link and light-variant label
inherited it. Mixing further down clears it for any accent the user
picks; filled surfaces read from --mantine-primary-color-filled and are
untouched.

Also points the accent-as-text CSS at --c-accent-text rather than
--c-accent-fg (a fill/icon colour) in the seven places that used it, and
fixes the file-origin badges, which set their label to the raw accent
and Mantine's orange over their own 16% tints.
2026-08-07 21:01:17 +01:00
Reece 707f68f721 fix(a11y): give every control, dialog, table header and progressbar a name
Clears the structural half of the grandfathered baseline. Fixed at the
shared component wherever one existed, so the story files themselves
needed almost no edits:

- progressbars: the shared ProgressBar now REQUIRES a label, so an
  unnamed bar can no longer be written; StepIndicator names itself and
  the billing meters take a caller-supplied one.
- dialogs: passing aria-label to Mantine's Modal does nothing — rest
  props land on the outer wrapper and aria-labelledby is overwritten
  after the spread — so the three affected modals compose
  Modal.Root/Modal.Content instead.
- table headers: action and chevron columns get screen-reader-only text
  through the existing .sr-only utility.
- inputs: named mostly by pointing aria-labelledby at the heading text
  already on screen, so screen-reader users get the same words sighted
  users read; SettingsRow and FormField now wire this automatically.
- ARIA semantics: popover targets became real controls rather than divs
  wearing aria-haspopup, role=dialog moved off <aside>, chips stopped
  nesting a button inside a button, and the file grid's role=row now
  owns real cells.

Also drops an inline colour on the admin banner's link that was
overriding the themed accent.

18 new i18n keys, each added to both locales.
2026-08-07 21:01:17 +01:00
Reece 8fe32659d3 fix(a11y): stop using fill colours as text
Mantine's c="blue"/"red"/"green"/"orange" resolve to the filled shade —
a background colour. As text on the page those land between 2.9:1 and
4.2:1. Point them at the theme's on-light text tokens, which are
contrast-checked and follow light/dark.
2026-08-07 21:01:17 +01:00
Reece 2b0f8abc2a fix(a11y): make Storybook faithful to production, then fix the contrast it exposed
The a11y baseline had grandfathered 839 story entries, 71% of them
color-contrast. Most of that was the harness lying: Storybook wrapped
every story in the Processor's Mantine theme (SuiProvider is mounted
only by PortalApp) and never loaded styles/theme.css, so the editor
theme's --color-* palette resolved to nothing and Mantine fell back to
its stock greys. Stories now get the theme their component runs under,
with the CSS vocab that theme needs.

That exposed the real failures underneath, all of which were colours
chosen as fills being reused as text:

- status accents: the -dark shades are the on-light text colours, so
  they now sit deep enough to clear 4.5:1 on the tinted surfaces they
  actually appear on (adds green-800, amber-700/800, red-700).
- filled accents: green and red fills darken so their white labels
  pass; amber keeps its hue and takes dark ink instead, because
  darkening amber far enough for white text turns it brown.
- new --c-accent-text for accent copy, which --c-primary and its hover
  mix were both too light for.
- form labels and the required asterisk were gray-400/base red (2.3:1
  and 3.4:1).
- Mantine's light variant derives its own label colour (~3.7:1); a
  css-variables resolver points each hue at the accessible shade.
- the theme defines red and aliases blue so components naming those
  colours stop falling through to Mantine's stock palette.

Also names Mantine's icon-only dismiss buttons, which had no
accessible text at all.
2026-08-07 21:01:16 +01:00
240 changed files with 4541 additions and 4166 deletions
+10
View File
@@ -210,6 +210,16 @@ tasks:
# task frontend:storybook:test -- Button
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
storybook:coverage:
desc: "Which rendered surfaces have a story, and what each gap would need"
summary: |
Reports coverage by import rather than by adjacent file, and classifies
every gap by the work a story needs — props, a context slice, MSW
handlers or router state. Pass --todo to list every gap, or
--area <path> to scope it.
cmds:
- node editor/scripts/storybook-coverage.mjs {{.CLI_ARGS}}
storybook:a11y:
desc: "a11y regression gate over every story: fail only on NEW axe violations"
deps: [prepare, storybook:browser]
+1
View File
@@ -49,3 +49,4 @@ test-results
/scripts/dev-update-test/screenshots/
/editor/src-tauri/tauri.conf.dev-update.json
.a11y-scan/
.a11y-acc/
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
// Triage helper: turns the raw a11y scan reports into a per-story, per-rule
// table with the offending element/colour detail, so each failure can be
// attributed to either the Storybook harness or the component itself.
//
// node a11y-triage.mjs [--in .a11y-scan] [--json out.json]
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const args = process.argv.slice(2);
const opt = (n, d) => {
const i = args.indexOf(n);
return i >= 0 ? args[i + 1] : d;
};
const inDir = opt("--in", ".a11y-scan");
const jsonOut = opt("--json", "");
const RULE_URL = /dequeuniversity\.com\/rules\/axe\/[\d.]+\/([a-z0-9-]+)/g;
const CONTRAST =
/contrast of ([\d.]+) \(foreground color: (#[0-9a-f]+), background color: (#[0-9a-f]+)/g;
const ELEMENT = /^\s*(<[^\n]{0,160})/gm;
const rows = [];
for (const f of readdirSync(inDir).filter((n) => n.endsWith(".json"))) {
const data = JSON.parse(readFileSync(join(inDir, f), "utf8"));
const visit = (node, name) => {
if (Array.isArray(node)) return node.forEach((n) => visit(n, name));
if (!node || typeof node !== "object") return;
const self = node.fullName || node.title || name;
if (Array.isArray(node.failureMessages) && node.failureMessages.length) {
const text = node.failureMessages.join("\n");
const rules = new Set([...text.matchAll(RULE_URL)].map((m) => m[1]));
const colours = [...text.matchAll(CONTRAST)].map((m) => ({
ratio: Number(m[1]),
fg: m[2],
bg: m[3],
}));
const elements = [...text.matchAll(ELEMENT)]
.map((m) => m[1].trim())
.filter((e) => e.startsWith("<"))
.slice(0, 4);
for (const rule of rules) {
rows.push({
story: self,
rule,
colours: rule === "color-contrast" ? colours : [],
elements,
});
}
}
for (const [k, v] of Object.entries(node)) {
if (k === "failureMessages") continue;
visit(v, self);
}
};
visit(data, undefined);
}
const byRule = {};
for (const r of rows) (byRule[r.rule] ??= []).push(r);
console.log(`stories with failures: ${new Set(rows.map((r) => r.story)).size}`);
console.log(`story-rule pairs: ${rows.length}\n`);
for (const [rule, list] of Object.entries(byRule).sort(
(a, b) => b[1].length - a[1].length,
)) {
console.log(`${String(list.length).padStart(4)} ${rule}`);
}
const pairs = {};
for (const r of rows)
for (const c of r.colours) {
const k = `${c.fg} on ${c.bg} (${c.ratio})`;
pairs[k] = (pairs[k] ?? 0) + 1;
}
if (Object.keys(pairs).length) {
console.log("\ncontrast pairs:");
for (const [k, n] of Object.entries(pairs).sort((a, b) => b[1] - a[1]))
console.log(`${String(n).padStart(4)} ${k}`);
}
if (jsonOut) {
writeFileSync(jsonOut, JSON.stringify(rows, null, 2));
console.log(`\nwrote ${jsonOut}`);
}
+69 -7
View File
@@ -18,7 +18,14 @@ 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 { PreferencesProvider } from "@core/contexts/PreferencesContext";
import { SidebarProvider } from "@core/contexts/SidebarContext";
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";
@@ -29,6 +36,11 @@ 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 Mantine theme resolves its palette through the --color-* vocab
// defined here. The app picks this up via styles/tailwind.css; Storybook has no
// tailwind entry, so without it every var(--color-*) in the theme is undefined
// and Mantine silently falls back to its stock palette.
import "@core/styles/theme.css";
import "@core/tokens/base.css";
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
@@ -190,6 +202,37 @@ const withLocale: Decorator = (Story, context) => {
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 =
@@ -201,25 +244,36 @@ const withProviders: Decorator = (Story, context) => {
// 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";
// Storybook titles are the routing key here: the Processor's stories are all
// filed under "Portal/".
const isPortalStory = (context.title ?? "").startsWith("Portal/");
return (
<MemoryRouter initialEntries={["/"]}>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<SchemeSetup scheme={colorScheme} />
<ThemeBridge theme={colorScheme}>
<SuiProvider colorScheme={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}>
<Story />
</Suspense>
</UIProvider>
{/* Tooltip reads the user's logo preference and the sidebar
geometry it positions against. It is used by ~100
components, so without these a story that renders one
throws. The real app always has both mounted. */}
<PreferencesProvider>
<SidebarProvider>
<UIProvider>
<Suspense fallback={null}>
<Story />
</Suspense>
</UIProvider>
</SidebarProvider>
</PreferencesProvider>
</TierKey>
</LinkProvider>
</SuiProvider>
</StoryTheme>
</ThemeBridge>
</ThemeProvider>
</QueryClientProvider>
@@ -246,6 +300,14 @@ const preview: Preview = {
// 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: {
@@ -2805,12 +2805,15 @@ tooltip = "Runs in the cloud (included, no extra charge)"
tooltip = "Pick colour from screen"
[colorPicker]
hue = "Hue"
saturation = "Saturation and brightness"
title = "Choose colour"
[common]
back = "Back"
cancel = "Cancel"
close = "Close"
codeSample = "Code sample"
collapse = "Collapse"
confirm = "Confirm"
continue = "Continue"
@@ -2826,6 +2829,7 @@ previous = "Previous"
refresh = "Refresh"
retry = "Retry"
save = "Save"
stepOf = "Step {{current}} of {{total}}"
[compare]
clearSelected = "Clear selected"
@@ -2988,6 +2992,7 @@ title = "Compression Method"
[compress.settings]
desiredSize = "Desired File Size"
desiredSizePlaceholder = "Enter size"
desiredSizeUnit = "Size unit"
[compress.tooltip.description]
text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually."
@@ -3644,11 +3649,13 @@ shareSelected = "Share Files"
sharing = "Sharing"
showAll = "Show All"
showHistory = "Show History"
sortBy = "Sort files"
sortByDate = "Sort by Date"
sortByName = "Sort by Name"
sortBySize = "Sort by Size"
storage = "Storage"
storageState = "Storage"
storageUsed = "Storage used"
synced = "Synced"
title = "Upload PDF Files"
toolChain = "Tools Applied"
@@ -4000,6 +4007,7 @@ noFilesInStorageOpen = "No files available in storage. Open some files first."
open = "Open"
openFile = "Open File"
openFiles = "Open Files"
selectFile = "Select {{name}}"
selectFromStorage = "Select from Storage"
upload = "Upload"
uploadFile = "Upload File"
@@ -4829,6 +4837,7 @@ filesReceived_other = "{{count}} files received"
instructions = "Scan with your phone camera. Images convert to PDF automatically."
instructionsNoConvert = "Scan with your phone camera to upload files."
pollingError = "Error checking for files"
qrCodeTitle = "QR code linking to the mobile upload page"
sessionCreateError = "Failed to create session"
title = "Upload from Mobile"
@@ -4966,6 +4975,7 @@ activeFiles = "The <strong>Active Files</strong> view shows all of the PDFs you
allTools = "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools."
close = "Close"
cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."
dialogLabel = "Onboarding"
fileCheckbox = "Clicking one of the files selects it for processing. You can select multiple files for batch operations."
fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."
filesButton = "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."
@@ -5362,6 +5372,7 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man
freeTitle = "Unlimited PDF editing"
[payg.free.hero]
barAria = "Free PDFs used"
capSuffix = "/ {{limit}} free PDFs"
metaCategories = "Automation · AI · API requests"
@@ -5419,6 +5430,7 @@ automation = "automations"
default = "this feature"
[payg.spendCapMeter]
barAria = "Spend against cap"
capSuffix = "/ {{amount}} cap"
metaCategories = "Automation · AI · API spend"
resets = "Resets each billing period"
@@ -5822,6 +5834,7 @@ small = "500 Credits"
xsmall = "100 Credits"
[plan.availablePlans]
currency = "Billing currency"
subtitle = "Choose the plan that fits your needs"
title = "Available Plans"
@@ -6221,6 +6234,7 @@ revoked = "Revoked"
unnamed = "Unnamed instance"
[portal.accountLink.instances.columns]
actions = "Actions"
instance = "Instance"
lastSeen = "Last seen"
linked = "Linked"
@@ -6510,6 +6524,7 @@ reachedTitle = "Monthly spend limit reached"
title = "Couldn't open Stripe portal"
[portal.billing.walletMeter]
barAria = "Free PDFs used"
capSuffix_one = "of {{allowance}} free PDFs used"
capSuffix_other = "of {{allowance}} free PDFs used"
eyebrow = "Processor trial"
@@ -6728,6 +6743,7 @@ sensitiveTitle = "Sensitive — access required"
[portal.documents.table.columns]
action = "Pipeline / Action"
actions = "Actions"
document = "Document"
product = "Product"
status = "Status"
@@ -6979,6 +6995,7 @@ title = "Create API key"
titleCreated = "Key created"
[portal.infrastructure.deployments]
loadAria = "Load for {{name}}"
msValue = "{{value}} ms"
throughputValue = "{{value}}/min"
@@ -7015,6 +7032,7 @@ title = "No regions deployed"
[portal.infrastructure.models]
heading = "Models"
loadAria = "Load for {{name}}"
msValue = "{{value}} ms"
subheading = "The model catalogue and routing that powers document processing across your workspace."
@@ -7322,6 +7340,7 @@ paused = "Paused"
[portal.pipelines.table]
name = "Pipeline"
open = "Open"
sources = "Sources"
status = "Status"
steps = "Steps"
@@ -7923,6 +7942,7 @@ disabled = "Disabled"
unused = "Unused"
[portal.sources.table]
open = "Open"
source = "Source"
status = "Status"
usedBy = "Policies"
@@ -2059,6 +2059,7 @@ title = "Smart Renaming"
[automate]
copyToSaved = "Copy to Saved"
runProgress = "Automation progress"
desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
export = "Export"
exportForFolderScanning = "Export for Folder Scanning"
@@ -2195,6 +2196,7 @@ applied = "{{degrees}}° CW"
confidenceValue = "Confidence {{value}}"
noChange = "No change"
pageLabel = "Page {{number}}"
pages = "Per-page results"
summary = "{{rotated}} of {{total}} pages rotated, {{unchanged}} left as they are"
title = "Detection report"
@@ -3001,12 +3003,15 @@ tooltip = "This operation will use your cloud credits"
tooltip = "Pick color from screen"
[colorPicker]
hue = "Hue"
saturation = "Saturation and brightness"
title = "Choose color"
[common]
back = "Back"
cancel = "Cancel"
close = "Close"
codeSample = "Code sample"
collapse = "Collapse"
confirm = "Confirm"
continue = "Continue"
@@ -3023,6 +3028,7 @@ refresh = "Refresh"
remaining = "Remaining"
retry = "Retry"
save = "Save"
stepOf = "Step {{current}} of {{total}}"
[compare]
clearSelected = "Clear selected"
@@ -3185,6 +3191,7 @@ title = "Compression Method"
[compress.settings]
desiredSize = "Desired File Size"
desiredSizePlaceholder = "Enter size"
desiredSizeUnit = "Size unit"
[compress.tooltip.description]
text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually."
@@ -3824,11 +3831,13 @@ shareSelected = "Share Files"
sharing = "Sharing"
showAll = "Show All"
showHistory = "Show History"
sortBy = "Sort files"
sortByDate = "Sort by Date"
sortByName = "Sort by Name"
sortBySize = "Sort by Size"
storage = "Storage"
storageState = "Storage"
storageUsed = "Storage used"
synced = "Synced"
title = "Upload PDF Files"
toolChain = "Tools Applied"
@@ -4168,6 +4177,7 @@ noFilesInStorageOpen = "No files available in storage. Open some files first."
open = "Open"
openFile = "Open File"
openFiles = "Open Files"
selectFile = "Select {{name}}"
selectFromStorage = "Select from Storage"
upload = "Upload"
uploadFile = "Upload File"
@@ -4241,6 +4251,7 @@ issues = "GitHub"
[formFill]
allSaved = "All saved"
completionProgress = "Form completion"
extractCsvError = "Failed to extract CSV"
extractXlsxError = "Failed to extract XLSX"
flattenAfterFilling = "Flatten after filling"
@@ -4992,6 +5003,7 @@ filesReceived_other = "{{count}} files received"
instructions = "Scan with your phone camera. Images convert to PDF automatically."
instructionsNoConvert = "Scan with your phone camera to upload files."
pollingError = "Error checking for files"
qrCodeTitle = "QR code linking to the mobile upload page"
sessionCreateError = "Failed to create session"
title = "Upload from Mobile"
@@ -5129,6 +5141,7 @@ activeFiles = "The <strong>Active Files</strong> view shows all of the PDFs you
allTools = "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools."
close = "Close"
cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."
dialogLabel = "Onboarding"
fileCheckbox = "Files on the workbench are selected for processing. You can select multiple files for batch operations using the left files sidebar."
fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."
filesButton = "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."
@@ -5565,6 +5578,7 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man
freeTitle = "Unlimited PDF editing"
[payg.free.hero]
barAria = "Free PDFs used"
capSuffix = "/ {{limit}} free PDFs"
metaCategories = "Automation · AI · API requests"
@@ -5644,6 +5658,7 @@ automation = "automations"
default = "this feature"
[payg.spendCapMeter]
barAria = "Spend against cap"
capSuffix = "/ {{amount}} cap"
metaCategories = "Automation · AI · API spend"
resets = "Resets each billing period"
@@ -6047,6 +6062,7 @@ small = "500 Credits"
xsmall = "100 Credits"
[plan.availablePlans]
currency = "Billing currency"
subtitle = "Choose the plan that fits your needs"
title = "Available Plans"
@@ -6282,6 +6298,7 @@ revoked = "Revoked"
unnamed = "Unnamed instance"
[portal.accountLink.instances.columns]
actions = "Actions"
instance = "Instance"
lastSeen = "Last seen"
linked = "Linked"
@@ -6626,6 +6643,7 @@ reachedTitle = "Monthly spend limit reached"
title = "Couldn't open Stripe portal"
[portal.billing.walletMeter]
barAria = "Free PDFs used"
capSuffix_one = "of {{allowance}} free PDFs used"
capSuffix_other = "of {{allowance}} free PDFs used"
eyebrow = "Processor trial"
@@ -7186,6 +7204,7 @@ sensitiveTitle = "Sensitive — access required"
[portal.documents.table.columns]
action = "Pipeline / Action"
actions = "Actions"
document = "Document"
product = "Product"
status = "Status"
@@ -7483,6 +7502,7 @@ rolledBack = "Rolled back"
rolling = "Rolling out"
[portal.infrastructure.deployments]
loadAria = "Load for {{name}}"
msValue = "{{value}} ms"
throughputValue = "{{value}}/min"
@@ -7528,6 +7548,7 @@ disabled = "Disabled"
[portal.infrastructure.models]
heading = "Models"
loadAria = "Load for {{name}}"
msValue = "{{value}} ms"
subheading = "The model catalogue and routing that powers document processing across your workspace."
@@ -7894,6 +7915,7 @@ paused = "Paused"
[portal.pipelines.table]
name = "Pipeline"
open = "Open"
sources = "Sources"
status = "Status"
steps = "Steps"
@@ -8785,6 +8807,7 @@ unused = "Unused"
[portal.sources.table]
documents = "Documents"
open = "Open"
source = "Source"
status = "Status"
usedBy = "Policies"
@@ -10845,6 +10868,7 @@ title = "Unlocked Forms Results"
[update]
allReleases = "All Releases"
downloadProgress = "Download progress"
breaking = "Breaking"
breakingChanges = "Breaking Changes"
breakingChangesDefault = "This version contains breaking changes."
@@ -11325,6 +11349,7 @@ delete = "Delete signature"
[viewer.thumbnails]
closeSidebar = "Close thumbnails sidebar"
goToPage = "Go to page {{page}}"
[viewPdf]
tags = "view,read,annotate,text,image,highlight,edit"
@@ -0,0 +1,249 @@
#!/usr/bin/env node
// Storybook coverage report — which rendered surfaces have a story, and what
// stands between the ones that don't and having one. Modes:
//
// node storybook-coverage.mjs summary by area (report only)
// node storybook-coverage.mjs --todo every uncovered surface, with the
// work each needs
// node storybook-coverage.mjs --area core/components/tools
//
// Coverage is counted by *import*, not by an adjacent .stories.tsx: several
// components are covered by a shared story file (MantineForms covers Select,
// MultiSelect, NumberInput and ColorInput between them), and counting siblings
// reports those as gaps and invites duplicate stories.
//
// Each uncovered surface is classified by what a story would have to supply:
//
// props no context to supply. Note this means "no provider needed", not
// "cheap" — a props-only component can still be expensive to
// story if its props are heavy (ButtonAppearanceOverlay wants
// real PDF bytes; AppConfigModalLazy lazy-loads the whole
// settings tree). Read the props before assuming it is quick.
// context it (or something it renders) reads a React context. The cheap
// fix is usually to export the context and hand the story the
// slice the component actually touches, rather than mounting the
// provider and whatever chain sits behind it.
// data it fetches, so the story needs MSW handlers
// router it reads router state
//
// Not counted as surfaces at all: providers, contexts, gates, routers, test
// helpers, and modules that return a config object rather than markup.
//
// Known blocker — four flavours cannot be storied as things stand.
//
// Storybook resolves @app/* through editor/tsconfig.proprietary.vite.json,
// which maps it to src/proprietary/* then src/core/* and excludes src/desktop.
// A file in another flavour that imports an @app/* asset living only in its own
// tree therefore fails to resolve, and the story file does not load at all:
//
// desktop 80 of 152 @app/ imports unresolvable
// saas 54 of 232
// cloud 28 of 77
// prototypes 4 of 40
// portal-saas 0 of 7 (fine)
//
// That accounts for the 0% areas below — it is a build-config gap, not
// neglect. Closing it means either per-flavour alias projects in
// .storybook/main.ts or hoisting the shared assets, and it is a decision with
// blast radius across every existing story, so it is deliberately not made
// here.
//
// Run from frontend/.
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, resolve } from "node:path";
const SRC = resolve(process.cwd(), "editor/src");
const args = process.argv.slice(2);
const wantTodo = args.includes("--todo");
const areaFilter = args.includes("--area")
? args[args.indexOf("--area") + 1]
: null;
/* ── file walk ────────────────────────────────────────────────────────────── */
function walk(dir, out = []) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.name.endsWith(".tsx")) out.push(full);
}
return out;
}
const all = walk(SRC);
const rel = (f) => relative(SRC, f).split("\\").join("/");
const storyFiles = all.filter((f) => f.endsWith(".stories.tsx"));
const sources = all.filter(
(f) => !f.endsWith(".stories.tsx") && !f.endsWith(".test.tsx"),
);
/* ── what the stories already reach ───────────────────────────────────────── */
const importedNames = new Set();
const importedPaths = new Set();
for (const f of storyFiles) {
const src = readFileSync(f, "utf8");
for (const m of src.matchAll(
/import\s+(?:type\s+)?(?:\{([^}]*)\}|(\w+))\s*(?:,\s*\{([^}]*)\})?\s*from\s+["']([^"']+)/g,
)) {
for (const group of [m[1], m[3]]) {
if (!group) continue;
for (const name of group.split(","))
importedNames.add(
name.trim().split(" as ")[0].replace("type ", "").trim(),
);
}
if (m[2]) importedNames.add(m[2]);
importedPaths.add(m[4]);
}
}
/* ── classification ───────────────────────────────────────────────────────── */
// Bridge: the viewer's *APIBridge components register an API into context and
// render null — wiring, like the rest of these.
const INFRA_NAME =
/(Provider|Providers|Context|Gate|Boundary|Mount|Router|Guard|Bridge)\.tsx$/;
const INFRA_DIR =
/\/(contexts|guards|test|tests|mocks|hooks|types|utils|api|data)\//;
// Hidden and obsolete — not coming back, so it is not a gap anyone should be
// spending stories on.
// Listed by name so the next one is a line rather than a regex edit, and so
// the reason a directory is absent from the report is visible here.
const OBSOLETE_DIRS = ["watchedFolders"];
const OBSOLETE_DIR = new RegExp(`/(${OBSOLETE_DIRS.join("|")})/`);
const RENDERS = /return\s*\(?\s*<|=>\s*\(?\s*</;
// A module whose exported function returns a config object, not markup.
const CONFIG_FACTORY = /:\s*(SlideConfig|ToolFlowConfig|\w+Config)\s*\{/;
const DATA = /\buse(Query|Mutation|SWR|InfiniteQuery)\b|\bfetch\w*\(/;
const ROUTER = /\buse(Navigate|Params|Location|SearchParams)\b/;
const CONTEXT = /\buse[A-Z]\w*\(/g;
// Hooks that are plainly not context reads.
const LOCAL_HOOK =
/^use(State|Effect|Memo|Callback|Ref|Id|Reducer|Context|Translation|LayoutEffect|ImperativeHandle|Transition|DeferredValue|SyncExternalStore|Debounced\w*|Media\w*|Disclosure|Form)$/;
// Contexts .storybook/preview.tsx already mounts for every story. A component
// that reads only these needs no fixture work, so it counts as props-level.
const HARNESS_PROVIDED =
/^use(Preferences|SidebarContext|Tier|Link|UI|Theme|QueryClient|Navigate|Location|Params|SearchParams)$/;
const byPath = new Map(sources.map((f) => [rel(f), f]));
function localImports(src, fromRel) {
const out = [];
for (const m of src.matchAll(
/from\s+["'](@app\/|@core\/|\.\.?\/)([^"']+)/g,
)) {
const spec = m[1] + m[2];
const guess = spec.replace(/^@app\//, "core/").replace(/^@core\//, "core/");
for (const cand of [`${guess}.tsx`, `${guess}/index.tsx`]) {
if (byPath.has(cand)) out.push(cand);
}
void fromRel;
}
return out;
}
/** Does this file, or anything it renders, read a context? Depth-limited. */
function needsContext(relPath, seen = new Set(), depth = 0) {
if (depth > 3 || seen.has(relPath)) return false;
seen.add(relPath);
const file = byPath.get(relPath);
if (!file) return false;
const src = readFileSync(file, "utf8");
for (const m of src.matchAll(CONTEXT)) {
const name = m[0].slice(0, -1);
if (!LOCAL_HOOK.test(name) && !HARNESS_PROVIDED.test(name)) return true;
}
return localImports(src, relPath).some((child) =>
needsContext(child, seen, depth + 1),
);
}
const rows = [];
for (const file of sources) {
const r = rel(file);
const base = r.split("/").pop();
if (!/^[A-Z]/.test(base)) continue;
const src = readFileSync(file, "utf8");
if (!RENDERS.test(src)) continue;
if (INFRA_NAME.test(base) || INFRA_DIR.test("/" + r)) continue;
if (OBSOLETE_DIR.test("/" + r)) continue;
if (CONFIG_FACTORY.test(src)) continue;
const stem = base.replace(".tsx", "");
const tail = r.replace(".tsx", "");
const covered =
importedNames.has(stem) ||
[...importedPaths].some((p) => p.endsWith(tail) || p.endsWith("/" + stem));
let needs = "props";
if (DATA.test(src)) needs = "data";
else if (ROUTER.test(src)) needs = "router";
else if (needsContext(r)) needs = "context";
const parts = r.split("/");
rows.push({
area: parts.slice(0, Math.min(3, parts.length - 1)).join("/"),
file: r,
covered,
needs,
loc: src.split("\n").length,
});
}
/* ── report ───────────────────────────────────────────────────────────────── */
const shown = areaFilter
? rows.filter((r) => r.file.startsWith(areaFilter))
: rows;
const todo = shown.filter((r) => !r.covered);
if (wantTodo || areaFilter) {
const order = { props: 0, context: 1, router: 2, data: 3 };
for (const r of todo.sort(
(a, b) => order[a.needs] - order[b.needs] || a.loc - b.loc,
)) {
console.log(
` ${r.needs.padEnd(8)} ${String(r.loc).padStart(5)} loc ${r.file}`,
);
}
console.log("");
}
const areas = new Map();
for (const r of shown) {
const a = areas.get(r.area) ?? { total: 0, covered: 0 };
a.total += 1;
if (r.covered) a.covered += 1;
areas.set(r.area, a);
}
console.log(
`${"area".padEnd(38)}${"total".padStart(6)}${"covered".padStart(9)}${"%".padStart(6)}`,
);
for (const [area, a] of [...areas].sort(
(x, y) => y[1].total - y[1].covered - (x[1].total - x[1].covered),
)) {
if (a.total === a.covered) continue;
const pct = Math.round((100 * a.covered) / a.total);
console.log(
`${area.padEnd(38)}${String(a.total).padStart(6)}${String(a.covered).padStart(9)}${String(pct).padStart(5)}%`,
);
}
const covered = shown.filter((r) => r.covered).length;
const byNeed = todo.reduce(
(acc, r) => ((acc[r.needs] = (acc[r.needs] ?? 0) + 1), acc),
{},
);
console.log(
`\nsurfaces ${shown.length} covered ${covered} (${Math.round((100 * covered) / shown.length)}%) remaining ${todo.length}`,
);
console.log(
`remaining by what a story needs: ` +
Object.entries(byNeed)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${k} ${v}`)
.join(" "),
);
@@ -95,7 +95,7 @@
margin-bottom: 8px;
}
.payg-planhead__lbl--free {
color: var(--c-success);
color: var(--color-green-dark);
}
.payg-planhead__lbl--meter {
color: var(--payg-accent);
@@ -242,7 +242,7 @@
background: color-mix(in srgb, var(--c-success) 14%, transparent);
}
[data-mantine-color-scheme="dark"] .payg-hero__credit {
color: var(--c-success);
color: var(--color-green-dark);
background: color-mix(in srgb, var(--c-success) 18%, transparent);
}
@@ -459,11 +459,11 @@
}
.payg-gate[data-enabled="true"] .payg-gate__chip {
background: color-mix(in srgb, var(--c-success) 16%, transparent);
color: var(--c-success);
color: var(--color-green-dark);
}
.payg-gate[data-enabled="false"] .payg-gate__chip {
background: color-mix(in srgb, var(--c-danger) 16%, transparent);
color: var(--c-danger);
color: var(--color-red-dark);
}
.payg-gate__label {
font-size: 0.8125rem;
@@ -486,11 +486,11 @@
background: var(--c-surface-sunken);
}
.payg-gate__tag[data-variant="pause"] {
color: var(--c-danger);
color: var(--color-red-dark);
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
}
[data-mantine-color-scheme="dark"] .payg-gate__tag[data-variant="pause"] {
color: var(--c-danger);
color: var(--color-red-dark);
background: color-mix(in srgb, var(--c-danger) 18%, transparent);
}
@@ -298,7 +298,7 @@
font-size: 1rem !important;
}
.paygf-explainer__icon--free {
color: var(--c-success);
color: var(--color-green-dark);
}
.paygf-explainer__icon--paid {
color: var(--payg-accent);
@@ -10,7 +10,7 @@
.scc {
--scc-accent: var(--c-primary);
--scc-accent-text: var(--c-primary);
--scc-accent-text: var(--c-accent-text);
--scc-accent-soft: color-mix(in srgb, var(--c-primary) 12%, transparent);
--scc-accent-border: color-mix(in srgb, var(--c-primary) 25%, transparent);
--scc-chip-bg: var(--c-surface-sunken);
@@ -24,7 +24,7 @@
[data-mantine-color-scheme="dark"] .scc {
/* Chip surface/border track the neutral --c-* tokens (base rule); only the
brand-azure accent is tuned brighter for dark. */
--scc-accent-text: var(--c-primary);
--scc-accent-text: var(--c-accent-text);
--scc-accent-soft: color-mix(in srgb, var(--c-primary) 16%, transparent);
}
@@ -60,6 +60,7 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
<MeterBar
state={state}
pct={pct}
barLabel={t("payg.free.hero.barAria", "Free PDFs used")}
figure={snap.billableUsed.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
limit: snap.billableLimit.toLocaleString(),
@@ -122,6 +123,7 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
<MeterBar
state={state}
pct={pct}
barLabel={t("payg.spendCapMeter.barAria", "Spend against cap")}
figure={`${symbol}${snap.spent.toLocaleString()}`}
capSuffix={t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", {
amount: `${symbol}${snap.cap.toLocaleString()}`,
@@ -193,6 +195,7 @@ export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
<MeterBar
state={state}
pct={pct}
barLabel={t("payg.prepaid.card.title", "Prepaid capacity")}
figure={snap.remaining.toLocaleString()}
capSuffix={t(
"payg.prepaid.meter.capSuffix",
@@ -39,6 +39,7 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
</Text>
{storageStats.quota && (
<Progress
aria-label={t("fileManager.storageUsed", "Storage used")}
value={storageUsagePercent}
color={
storageUsagePercent > 80
@@ -51,6 +51,13 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
format="hex"
value={selectedColor}
onChange={onColorChange}
// The saturation area and hue bar are role="slider" divs; these are
// their only accessible names.
saturationLabel={t(
"colorPicker.saturation",
"Saturation and brightness",
)}
hueLabel={t("colorPicker.hue", "Hue")}
swatches={[
"#000000",
"#0066cc",
@@ -73,6 +80,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
max={100}
value={opacity}
onChange={onOpacityChange}
thumbLabel={resolvedOpacityLabel}
marks={[
{ value: 25, label: "25%" },
{ value: 50, label: "50%" },
@@ -161,7 +161,7 @@ const AddFileCard = ({
icon={icons.uploadIconName}
width="1.25rem"
height="1.25rem"
style={{ color: "var(--c-primary)", flexShrink: 0 }}
style={{ color: "var(--c-accent-text)", flexShrink: 0 }}
/>
{isUploadHover && (
<span
@@ -107,7 +107,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{currentFile && ` • v${currentFile.versionNumber || 1}`}
</Text>
{hasMultipleFiles && (
<Text size="xs" c="blue">
<Text size="xs" c="var(--c-accent-text)">
{currentFileIndex + 1} of {selectedFiles.length}
</Text>
)}
@@ -103,7 +103,7 @@ const EmptyFilesState: React.FC = () => {
icon={icons.uploadIconName}
width="1.25rem"
height="1.25rem"
style={{ color: "var(--c-primary)" }}
style={{ color: "var(--c-accent-text)" }}
/>
{isUploadHover && (
<span style={{ marginLeft: ".5rem" }}>
@@ -86,19 +86,29 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
}}
>
<Box
bg="gray.4"
p="sm"
style={{
background: "var(--c-surface-raised)",
borderTopLeftRadius: "var(--mantine-radius-md)",
borderTopRightRadius: "var(--mantine-radius-md)",
flexShrink: 0,
}}
>
<Text size="sm" fw={500} ta="center" c="white">
<Text size="sm" fw={500} ta="center">
{t("fileManager.details", "File Details")}
</Text>
</Box>
<ScrollArea style={{ flex: 1, minHeight: 0 }} p="md">
{/* The viewport is focusable and named so keyboard users can scroll the
detail list once it overflows. */}
<ScrollArea
style={{ flex: 1, minHeight: 0 }}
p="md"
viewportProps={{
tabIndex: 0,
role: "group",
"aria-label": t("fileManager.details", "File Details"),
}}
>
<Stack gap="sm">
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
@@ -208,11 +208,13 @@ const FileListItem: React.FC<FileListItemProps> = ({
>
<Group gap="sm">
{!isHistoryFile && (
<Box>
{/* Checkbox for regular files only */}
<Box onClick={(e) => e.stopPropagation()}>
{/* The row's own onClick is mouse-only, so the checkbox has to
carry the keyboard path rather than deferring to it. Its
click stops above so a mouse press doesn't toggle twice. */}
<Checkbox
checked={isSelected}
onChange={() => {}} // Handled by parent onClick
onChange={() => onSelect(false)}
size="sm"
pl="sm"
pr="xs"
@@ -906,35 +906,47 @@ function ListView({
return (
<div className="files-page-list" role="grid">
{/* Each direct child is a columnheader: a role="row" may only own cells, so
the sort controls and the select-all box have to sit inside one. */}
<div className="files-page-list-row is-header" role="row">
{onSetSelection && visibleFileIds.length > 0 ? (
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={() => {
onSetSelection(allSelected ? new Set() : new Set(visibleFileIds));
}}
aria-label={
allSelected
? t("filesPage.deselectAll", "Clear selection")
: t("filesPage.selectAll", "Select all")
}
/>
<span role="columnheader">
<Checkbox
checked={allSelected}
indeterminate={someSelected}
onChange={() => {
onSetSelection(
allSelected ? new Set() : new Set(visibleFileIds),
);
}}
aria-label={
allSelected
? t("filesPage.deselectAll", "Clear selection")
: t("filesPage.selectAll", "Select all")
}
/>
</span>
) : (
<span aria-hidden="true" />
)}
<span {...headerProps("name-asc", "name-desc")}>
{t("filesPage.column.name", "Name")}
{sortIndicator("name-asc", "name-desc")}
<span role="columnheader">
<span {...headerProps("name-asc", "name-desc")}>
{t("filesPage.column.name", "Name")}
{sortIndicator("name-asc", "name-desc")}
</span>
</span>
<span>{t("filesPage.column.type", "Type")}</span>
<span {...headerProps("size-asc", "size-desc")}>
{t("filesPage.column.size", "Size")}
{sortIndicator("size-asc", "size-desc")}
<span role="columnheader">{t("filesPage.column.type", "Type")}</span>
<span role="columnheader">
<span {...headerProps("size-asc", "size-desc")}>
{t("filesPage.column.size", "Size")}
{sortIndicator("size-asc", "size-desc")}
</span>
</span>
<span {...headerProps("modified-asc", "modified-desc")}>
{t("filesPage.column.modified", "Modified")}
{sortIndicator("modified-asc", "modified-desc")}
<span role="columnheader">
<span {...headerProps("modified-asc", "modified-desc")}>
{t("filesPage.column.modified", "Modified")}
{sortIndicator("modified-asc", "modified-desc")}
</span>
</span>
<span aria-hidden="true" />
</div>
@@ -1091,7 +1103,12 @@ function FolderRow({
className={`files-page-list-row${isDropTarget ? " is-drop-target" : ""}`}
>
<span aria-hidden="true" />
<span style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{/* Each direct child is a gridcell: a role="row" may only own cells, so the
actions menu has to sit inside one. */}
<span
role="gridcell"
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
>
<FolderThumbnail
color={folder.color}
size="row"
@@ -1119,61 +1136,65 @@ function FolderRow({
)}
</span>
</span>
<span>{t("filesPage.folder", "Folder")}</span>
<span>
<span role="gridcell">{t("filesPage.folder", "Folder")}</span>
<span role="gridcell">
{fileCount === 0
? "-"
: t("filesPage.folderItems", "{{count}} items", { count: fileCount })}
</span>
<span>{getFileDate({ lastModified: folder.updatedAt })}</span>
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
ref={kebabRef}
variant="tertiary"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.folderMenu", "Folder actions")}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={onOpen}
>
{t("filesPage.open", "Open")}
</Menu.Item>
<Menu.Item
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
onClick={onRename}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.rename", "Rename")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>
{t("filesPage.appearance.title", "Appearance")}
</Menu.Label>
<FolderAppearancePicker
folder={folder}
onChange={onChangeAppearance}
disabled={!serverReachable}
/>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={onDelete}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.deleteFolder", "Delete folder")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<span role="gridcell">
{getFileDate({ lastModified: folder.updatedAt })}
</span>
<span role="gridcell">
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
ref={kebabRef}
variant="tertiary"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.folderMenu", "Folder actions")}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={onOpen}
>
{t("filesPage.open", "Open")}
</Menu.Item>
<Menu.Item
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
onClick={onRename}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.rename", "Rename")}
</Menu.Item>
<Menu.Divider />
<Menu.Label>
{t("filesPage.appearance.title", "Appearance")}
</Menu.Label>
<FolderAppearancePicker
folder={folder}
onChange={onChangeAppearance}
disabled={!serverReachable}
/>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={onDelete}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.deleteFolder", "Delete folder")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</span>
</div>
);
}
@@ -1254,35 +1275,40 @@ function FileRow({
isInWorkspace ? " is-in-workspace" : ""
}`}
>
{/* Checkbox only shows in multi-select mode (see FileCard). When the
checkbox is hidden the first grid column collapses, but the row's
CSS grid keeps the columns aligned via the named template, so no
empty cell shows. */}
{/* Each direct child is a gridcell: a role="row" may only own cells, so the
checkbox and the actions menu have to sit inside one.
The checkbox only shows in multi-select mode (see FileCard). When it is
hidden the first grid column collapses, but the row's CSS grid keeps the
columns aligned via the named template, so no empty cell shows. */}
{multiSelectActive ? (
<Checkbox
checked={isSelected}
onClick={(e) => {
// Toggle this file in/out of the selection without modifier keys.
e.stopPropagation();
onClick({
...e,
shiftKey: false,
ctrlKey: true,
metaKey: true,
} as unknown as React.MouseEvent);
}}
onChange={() => {
/* handled by onClick */
}}
aria-label={t("filesPage.selectFile", "Select file {{name}}", {
name: file.name,
})}
/>
<span role="gridcell">
<Checkbox
checked={isSelected}
onClick={(e) => {
// Toggle this file in/out of the selection without modifier keys.
e.stopPropagation();
onClick({
...e,
shiftKey: false,
ctrlKey: true,
metaKey: true,
} as unknown as React.MouseEvent);
}}
onChange={() => {
/* handled by onClick */
}}
aria-label={t("filesPage.selectFile", "Select file {{name}}", {
name: file.name,
})}
/>
</span>
) : (
// Empty cell preserves grid column alignment.
<span aria-hidden="true" />
)}
<span
role="gridcell"
style={{
display: "flex",
alignItems: "center",
@@ -1342,94 +1368,96 @@ function FileRow({
</span>
)}
</span>
<span>{ext || t("filesPage.file", "File")}</span>
<span>{fileSize}</span>
<span>{fileDate}</span>
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
ref={kebabRef}
variant="tertiary"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
data-testid="file-card-actions"
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
{t("filesPage.addToWorkspace", "Add to workspace")}
</Menu.Item>
<OpenInNewWindowMenuItem file={file} />
<Menu.Item
leftSection={<DriveFileMoveIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onMove();
}}
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
{/* Per-file Save to server; shown for local-only files. When
storage is off it stays visible but disabled with a tooltip. */}
{onSaveToServer && file.remoteStorageId == null && (
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
position="left"
multiline
w={240}
<span role="gridcell">{ext || t("filesPage.file", "File")}</span>
<span role="gridcell">{fileSize}</span>
<span role="gridcell">{fileDate}</span>
<span role="gridcell">
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
ref={kebabRef}
variant="tertiary"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
data-testid="file-card-actions"
>
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
style={
saveToServerDisabledReason
? { pointerEvents: "auto" }
: undefined
}
>
{t("filesPage.saveToServer", "Save to server")}
</Menu.Item>
</Tooltip>
)}
{onVersionHistory && (file.versionNumber ?? 1) > 1 && (
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<HistoryIcon fontSize="small" />}
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onVersionHistory();
onOpen();
}}
>
{t("filesPage.versionHistory", "Version history")}
{t("filesPage.addToWorkspace", "Add to workspace")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
{t("filesPage.remove", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<OpenInNewWindowMenuItem file={file} />
<Menu.Item
leftSection={<DriveFileMoveIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onMove();
}}
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
{/* Per-file Save to server; shown for local-only files. When
storage is off it stays visible but disabled with a tooltip. */}
{onSaveToServer && file.remoteStorageId == null && (
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
position="left"
multiline
w={240}
>
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
style={
saveToServerDisabledReason
? { pointerEvents: "auto" }
: undefined
}
>
{t("filesPage.saveToServer", "Save to server")}
</Menu.Item>
</Tooltip>
)}
{onVersionHistory && (file.versionNumber ?? 1) > 1 && (
<Menu.Item
leftSection={<HistoryIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onVersionHistory();
}}
>
{t("filesPage.versionHistory", "Version history")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
{t("filesPage.remove", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</span>
</div>
);
}
@@ -32,12 +32,12 @@ const styles = {
},
cloud: {
background: "color-mix(in srgb, var(--c-primary) 16%, transparent)",
color: "var(--c-primary)",
color: "var(--c-accent-text)",
},
shared: {
background:
"color-mix(in srgb, var(--mantine-color-orange-6) 16%, transparent)",
color: "var(--mantine-color-orange-6)",
color: "var(--color-amber-dark)",
},
};
@@ -339,6 +339,9 @@
}
.files-page-list-row.is-header [data-sortable="true"] {
/* Block so the hit area and hover tint fill the columnheader cell that wraps
it, rather than hugging the label text. */
display: block;
cursor: pointer;
padding: 0.2rem 0.4rem;
margin: -0.2rem -0.4rem;
@@ -741,7 +744,7 @@
height: 5rem;
border-radius: 50%;
background: color-mix(in srgb, var(--c-primary) 12%, transparent);
color: var(--c-primary);
color: var(--c-accent-text);
margin-bottom: 0.25rem;
}
@@ -980,7 +983,7 @@
.files-page-details-version-timeline-count {
margin-left: auto;
font-weight: 600;
color: var(--c-primary);
color: var(--c-accent-text);
text-transform: none;
letter-spacing: 0;
}
@@ -1112,7 +1115,29 @@
.files-page-details-version-timeline-expand-btn:hover span {
color: var(--c-text);
}
.files-page-details-version-timeline-delta {
font-size: 0.82rem;
color: var(--c-text);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
}
.files-page-details-version-timeline-delta.is-origin {
font-weight: 400;
color: var(--c-text-subtle);
font-style: italic;
}
.files-page-details-version-timeline-delta-plus {
color: var(--c-accent-text);
font-weight: 700;
}
.files-page-details-version-timeline-spacer {
flex: 1;
}
.files-page-details-version-timeline-chevron {
color: var(--c-text-subtle);
transition: transform 0.15s ease;
@@ -1120,7 +1145,7 @@
.files-page-details-version-timeline-chevron.is-expanded {
transform: rotate(180deg);
color: var(--c-primary);
color: var(--c-accent-text);
}
.files-page-details-version-timeline-expanded {
@@ -1185,7 +1210,7 @@
justify-content: center;
gap: 1rem;
pointer-events: none;
color: var(--c-primary);
color: var(--c-accent-text);
font-weight: 600;
font-size: 1.1rem;
z-index: 10;
@@ -151,7 +151,10 @@ export function FolderThumbnail({
borderRadius: "999px",
background: "var(--c-surface, #fff)",
border: `1px solid ${accent}`,
color: accent,
// The ring carries the folder's accent; the numeral does not.
// Folder colours are user-chosen and many are too light to read
// as text on the white pill.
color: "var(--c-text)",
fontSize: "0.7rem",
fontWeight: 700,
display: "inline-flex",
@@ -0,0 +1,75 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
HotkeyContext,
type HotkeyContextValue,
} from "@app/contexts/HotkeyContext";
import { HotkeyDisplay } from "@app/components/hotkeys/HotkeyDisplay";
import { getDisplayParts } from "@app/utils/hotkeys";
/** A keyboard shortcut rendered as key caps.
*
* HotkeyProvider pulls in the whole tool-workflow chain, but the display only
* needs one function off the context — so the stories supply that slice, using
* the real formatter so the caps render exactly as they do in the app. Pinned
* to the non-mac glyphs to keep the stories stable across machines. */
const withHotkeys = (Story: React.ComponentType) => (
<HotkeyContext.Provider
value={
{
getDisplayParts: (binding) => getDisplayParts(binding, false),
} as HotkeyContextValue
}
>
<Story />
</HotkeyContext.Provider>
);
const meta: Meta<typeof HotkeyDisplay> = {
title: "Hotkeys/HotkeyDisplay",
component: HotkeyDisplay,
parameters: { layout: "centered" },
decorators: [withHotkeys],
};
export default meta;
type Story = StoryObj<typeof HotkeyDisplay>;
/** A single key. */
export const SingleKey: Story = { args: { binding: { code: "KeyS" } } };
/** The common save shortcut. */
export const WithModifier: Story = {
args: { binding: { code: "KeyS", ctrl: true } },
};
/** Several modifiers at once. */
export const MultipleModifiers: Story = {
args: { binding: { code: "KeyP", ctrl: true, shift: true, alt: true } },
};
/** The macOS command modifier. */
export const MetaModifier: Story = {
args: { binding: { code: "KeyK", meta: true } },
};
/** A non-letter key, which renders its own glyph rather than a letter. */
export const ArrowKey: Story = { args: { binding: { code: "ArrowRight" } } };
/** Both sizes side by side. */
export const Sizes: Story = {
render: () => (
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
<HotkeyDisplay binding={{ code: "KeyS", ctrl: true }} size="sm" />
<HotkeyDisplay binding={{ code: "KeyS", ctrl: true }} size="md" />
</div>
),
};
/** Muted, for a shortcut shown beside a disabled action. */
export const Muted: Story = {
args: { binding: { code: "KeyS", ctrl: true }, muted: true },
};
/** No binding assigned — the component renders nothing rather than an empty
* cap, so an unbound action shows no stray chrome. */
export const Unbound: Story = { args: { binding: null } };
@@ -325,7 +325,7 @@
.v2Badge {
background: var(--c-primary-tint);
color: var(--c-accent-fg);
color: var(--c-accent-text);
padding: 3px 9px;
border-radius: 6px;
font-size: 12px;
@@ -77,3 +77,72 @@ export const NotDismissible: Story = {
allowDismiss: false,
},
};
/** The final step — the bar is fully filled and the primary action closes the
* tour rather than advancing it. */
export const LastStep: Story = {
args: {
hero: <ShellHero appIcon />,
slideKey: "done",
title: "You're all set",
body: "You can reopen this tour any time from the help menu.",
stepIndex: 4,
stepCount: 5,
buttons: [
{ key: "back", back: true, action: "back" },
{ key: "finish", label: "Finish", primary: true, action: "finish" },
],
onAction: () => {},
onClose: () => {},
},
};
/** An action that is not yet available — shown rather than hidden, so the path
* forward stays visible. */
export const DisabledAction: Story = {
args: {
hero: <ShellHero>1</ShellHero>,
slideKey: "choose",
title: "Choose your install",
body: "Pick a platform to continue.",
stepIndex: 1,
stepCount: 4,
buttons: [
{ key: "back", back: true, action: "back" },
{
key: "next",
label: "Download",
primary: true,
action: "next",
disabled: true,
},
],
onAction: () => {},
onClose: () => {},
},
};
/** A body longer than the viewport must scroll inside the card rather than
* pushing the actions off-screen. */
export const LongBody: Story = {
args: {
hero: <ShellHero appIcon />,
slideKey: "release-notes",
title: "What changed in this release",
body: (
<div style={{ display: "grid", gap: "0.75rem", textAlign: "left" }}>
{Array.from({ length: 12 }, (_, i) => (
<p key={i} style={{ margin: 0 }}>
{i + 1}. Batch processing now runs pipelined rather than serialised,
so a queue finishes in roughly the time the slowest document takes.
</p>
))}
</div>
),
stepIndex: 0,
stepCount: 1,
buttons: [{ key: "ok", label: "Got it", primary: true, action: "close" }],
onAction: () => {},
onClose: () => {},
},
};
@@ -101,7 +101,10 @@ export default function OnboardingSlideShell({
);
return (
<Modal
// Composed rather than the plain <Modal>, because only Modal.Content lands
// props on the role="dialog" element — the slide draws its own title, so the
// dialog needs an aria-label to have an accessible name.
<Modal.Root
opened={opened}
onClose={onClose}
closeOnClickOutside={false}
@@ -109,7 +112,6 @@ export default function OnboardingSlideShell({
centered
size="lg"
radius={20}
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
@@ -121,106 +123,122 @@ export default function OnboardingSlideShell({
},
}}
>
<div className={styles.card}>
<header className={styles.header}>
<div className={styles.brand}>
<img
src={stirlingMark}
alt=""
aria-hidden="true"
className={styles.brandLogo}
/>
<span className={styles.wordmark}>Stirling</span>
</div>
<div className={styles.headerRight}>
{showProgress && (
<span className={styles.stepPill}>
{t("onboarding.stepOf", "Step {{current}} of {{total}}", {
current: stepIndex + 1,
total: stepCount,
})}
</span>
)}
{allowDismiss && (
<ActionIcon
onClick={onClose}
variant="tertiary"
accent="neutral"
size="md"
aria-label={t("common.close", "Close")}
>
<LocalIcon
icon="close-rounded"
width="1.1rem"
height="1.1rem"
<Modal.Overlay />
<Modal.Content
radius={20}
aria-label={t("onboarding.dialogLabel", "Onboarding")}
>
<Modal.Body>
<div className={styles.card}>
<header className={styles.header}>
<div className={styles.brand}>
<img
src={stirlingMark}
alt=""
aria-hidden="true"
className={styles.brandLogo}
/>
</ActionIcon>
)}
</div>
</header>
<span className={styles.wordmark}>Stirling</span>
</div>
<div className={styles.headerRight}>
{showProgress && (
<span className={styles.stepPill}>
{t("onboarding.stepOf", "Step {{current}} of {{total}}", {
current: stepIndex + 1,
total: stepCount,
})}
</span>
)}
{allowDismiss && (
<ActionIcon
onClick={onClose}
variant="tertiary"
accent="neutral"
size="md"
aria-label={t("common.close", "Close")}
>
<LocalIcon
icon="close-rounded"
width="1.1rem"
height="1.1rem"
/>
</ActionIcon>
)}
</div>
</header>
{showProgress && (
<div
className={styles.progressTrack}
role="progressbar"
aria-valuenow={stepIndex + 1}
aria-valuemin={1}
aria-valuemax={stepCount}
>
{Array.from({ length: stepCount }, (_, index) => (
<span
key={index}
className={`${styles.progressSeg} ${
index <= stepIndex ? styles.progressSegDone : ""
}`}
/>
))}
</div>
)}
<div className={styles.divider} />
<div className={styles.content}>
<div className={styles.heroPanel}>
<div className={styles.heroArt} key={`hero-${slideKey}`}>
{hero}
</div>
</div>
<div key={`title-${slideKey}`} className={styles.titleNew}>
{title}
</div>
<div key={`body-${slideKey}`} className={styles.bodyNew}>
{body}
<style>{`.${styles.bodyNew} strong{color: var(--c-text); font-weight: 600;}`}</style>
</div>
<div className={styles.footer}>
{backButtons.length === 0 ? (
<div className={styles.footerEnd}>{actions}</div>
) : (
<div className={styles.footerBetween}>
<div className={styles.footerGroup}>
{backButtons.map((button) => (
<ActionIcon
key={button.key}
onClick={() => onAction(button.action)}
variant="tertiary"
accent="neutral"
disabled={button.disabled}
aria-label={t("onboarding.buttons.back", "Back")}
>
<ChevronLeftIcon fontSize="small" />
</ActionIcon>
))}
</div>
{actions}
{showProgress && (
<div
className={styles.progressTrack}
role="progressbar"
aria-valuenow={stepIndex + 1}
aria-valuemin={1}
aria-valuemax={stepCount}
aria-label={t(
"onboarding.stepOf",
"Step {{current}} of {{total}}",
{
current: stepIndex + 1,
total: stepCount,
},
)}
>
{Array.from({ length: stepCount }, (_, index) => (
<span
key={index}
className={`${styles.progressSeg} ${
index <= stepIndex ? styles.progressSegDone : ""
}`}
/>
))}
</div>
)}
<div className={styles.divider} />
<div className={styles.content}>
<div className={styles.heroPanel}>
<div className={styles.heroArt} key={`hero-${slideKey}`}>
{hero}
</div>
</div>
<div key={`title-${slideKey}`} className={styles.titleNew}>
{title}
</div>
<div key={`body-${slideKey}`} className={styles.bodyNew}>
{body}
<style>{`.${styles.bodyNew} strong{color: var(--c-text); font-weight: 600;}`}</style>
</div>
<div className={styles.footer}>
{backButtons.length === 0 ? (
<div className={styles.footerEnd}>{actions}</div>
) : (
<div className={styles.footerBetween}>
<div className={styles.footerGroup}>
{backButtons.map((button) => (
<ActionIcon
key={button.key}
onClick={() => onAction(button.action)}
variant="tertiary"
accent="neutral"
disabled={button.disabled}
aria-label={t("onboarding.buttons.back", "Back")}
>
<ChevronLeftIcon fontSize="small" />
</ActionIcon>
))}
</div>
{actions}
</div>
)}
</div>
</div>
</div>
</div>
</div>
</Modal>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -125,7 +125,7 @@ function FirstLoginForm({
icon="info-rounded"
width={20}
height={20}
style={{ color: "var(--c-primary)", flexShrink: 0 }}
style={{ color: "var(--c-accent-text)", flexShrink: 0 }}
/>
<span>
{t(
@@ -26,7 +26,7 @@ export default function SecurityCheckSlide({
icon="error"
width={20}
height={20}
style={{ color: "var(--c-danger)", flexShrink: 0 }}
style={{ color: "var(--color-red-dark)", flexShrink: 0 }}
/>
<span>
{i18n.t(
@@ -0,0 +1,93 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel";
/** A document of `n` pages in the shape the panel reads. */
const doc = (n: number) => ({
pages: Array.from({ length: n }, (_, i) => ({
id: `page-${i + 1}`,
pageNumber: i + 1,
})),
});
/**
* Selecting pages by typing a range rather than clicking thumbnails. The CSV
* field is the whole point of the panel, so the stories drive it with real
* state — typing into a static snapshot would prove nothing.
*/
const meta: Meta<typeof BulkSelectionPanel> = {
title: "PageEditor/BulkSelectionPanel",
component: BulkSelectionPanel,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof BulkSelectionPanel>;
function Demo({
initialCsv = "",
selected = [],
pages = 24,
}: {
initialCsv?: string;
selected?: string[];
pages?: number;
}) {
const [csvInput, setCsvInput] = useState(initialCsv);
return (
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPageIds={selected}
displayDocument={doc(pages)}
onUpdatePagesFromCSV={() => {}}
/>
);
}
/** Nothing selected yet. */
export const Empty: Story = { render: () => <Demo /> };
/** A typed range, with the matching pages selected. */
export const WithRange: Story = {
render: () => (
<Demo
initialCsv="1-5"
selected={["page-1", "page-2", "page-3", "page-4", "page-5"]}
/>
),
};
/** A mixed expression — individual pages and ranges together. */
export const MixedExpression: Story = {
render: () => (
<Demo initialCsv="1,4-6,12" selected={["page-1", "page-4", "page-12"]} />
),
};
/** Every page selected. */
export const AllSelected: Story = {
render: () => (
<Demo
pages={8}
initialCsv="1-8"
selected={Array.from({ length: 8 }, (_, i) => `page-${i + 1}`)}
/>
),
};
/** A single-page document, where ranges have little to do. */
export const SinglePage: Story = {
render: () => <Demo pages={1} />,
};
/** A long document, to check the summary stays readable as counts grow. */
export const LongDocument: Story = {
render: () => (
<Demo
pages={480}
initialCsv="1-200"
selected={Array.from({ length: 200 }, (_, i) => `page-${i + 1}`)}
/>
),
};
@@ -0,0 +1,69 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
const doc = (n: number) => ({
pages: Array.from({ length: n }, (_, i) => ({
id: `page-${i + 1}`,
pageNumber: i + 1,
})),
});
/**
* The toolbar affordance that opens bulk page selection. It disables itself
* when there are no pages to select, so an empty document offers no dead
* control.
*/
const meta: Meta<typeof PageSelectByNumberButton> = {
title: "PageEditor/PageSelectByNumberButton",
component: PageSelectByNumberButton,
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj<typeof PageSelectByNumberButton>;
function Demo({
totalPages = 24,
disabled = false,
initialCsv = "",
selected = [],
}: {
totalPages?: number;
disabled?: boolean;
initialCsv?: string;
selected?: string[];
}) {
const [csvInput, setCsvInput] = useState(initialCsv);
return (
<PageSelectByNumberButton
disabled={disabled}
totalPages={totalPages}
label="Select pages by number"
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPageIds={selected}
displayDocument={doc(totalPages)}
updatePagesFromCSV={() => {}}
/>
);
}
/** Available — click to open the selection popover. */
export const Default: Story = { render: () => <Demo /> };
/** A selection already in place. */
export const WithSelection: Story = {
render: () => (
<Demo initialCsv="2,5-9" selected={["page-2", "page-5", "page-9"]} />
),
};
/** Explicitly disabled, e.g. while the document is still loading. */
export const Disabled: Story = { render: () => <Demo disabled /> };
/** No pages: the control disables itself regardless of the `disabled` prop. */
export const NoPages: Story = { render: () => <Demo totalPages={0} /> };
/** A single page — selectable, but ranges have little to do. */
export const SinglePage: Story = { render: () => <Demo totalPages={1} /> };
@@ -35,16 +35,17 @@ export default function PageSelectByNumberButton({
>
<div>
<Popover position="left" withArrow shadow="md" offset={8}>
{/* The button is the target, not a wrapper: Popover.Target puts
aria-haspopup and aria-expanded on whatever it wraps, and
aria-expanded is not a permitted attribute on a plain div. */}
<Popover.Target>
<div style={{ display: "inline-flex" }}>
<ActionIcon
variant="tertiary"
disabled={disabled || totalPages === 0}
aria-label={label}
>
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
<ActionIcon
variant="tertiary"
disabled={disabled || totalPages === 0}
aria-label={label}
>
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: "24rem", maxWidth: "32rem" }}>
@@ -60,7 +60,7 @@ const PageSelectionInput = ({
size="sm"
checked={!!advancedOpened}
onChange={(e) => onToggleAdvanced?.(e.currentTarget.checked)}
title={t("bulkSelection.advanced.title", "Advanced")}
aria-label={t("bulkSelection.advanced.title", "Advanced")}
className={classes.advancedSwitch}
/>
</Flex>
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
/**
* The sidebar brand header. Core has no admin portal to switch to, so this is
* just the logo — builds that bundle the portal shadow this file with a version
* whose logo doubles as the editor⇄processor switcher. Both states matter here
* because the rail collapses.
*/
const meta: Meta<typeof AppSwitcher> = {
title: "Shared/AppSwitcher",
component: AppSwitcher,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof AppSwitcher>;
/** Expanded rail — mark and wordmark. */
export const Expanded: Story = {};
/** Collapsed rail — icon only. */
export const Collapsed: Story = { args: { collapsed: true } };
/** Both, to compare the mark's optical size between the two rail widths. */
export const BothStates: Story = {
render: () => (
<div style={{ display: "flex", gap: "3rem", alignItems: "center" }}>
<AppSwitcher />
<AppSwitcher collapsed />
</div>
),
};
@@ -0,0 +1,66 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import Badge from "@app/components/shared/Badge";
/** Small inline label. `colored` takes an explicit palette so callers can tint
* a badge to whatever the surrounding feature already uses. */
const meta: Meta<typeof Badge> = {
title: "Shared/Badge",
component: Badge,
parameters: { layout: "centered" },
args: { children: "Beta" },
};
export default meta;
type Story = StoryObj<typeof Badge>;
/** Default tone. */
export const Default: Story = {};
/** The three sizes together, so their baselines can be compared. */
export const Sizes: Story = {
render: () => (
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
<Badge size="sm">Small</Badge>
<Badge size="md">Medium</Badge>
<Badge size="lg">Large</Badge>
</div>
),
};
/** Explicitly tinted. The pairing is the caller's to get right — these use the
* semantic tokens rather than raw hues so they hold up in both themes. */
export const Colored: Story = {
render: () => (
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
<Badge
variant="colored"
backgroundColor="var(--c-success-solid)"
textColor="var(--c-text-on-primary)"
>
Active
</Badge>
<Badge
variant="colored"
backgroundColor="var(--c-warning-solid)"
textColor="var(--c-text-on-primary)"
>
Pending
</Badge>
<Badge
variant="colored"
backgroundColor="var(--c-danger-solid)"
textColor="var(--c-text-on-primary)"
>
Failed
</Badge>
</div>
),
};
/** A long label, to check it stays on one line rather than breaking the row. */
export const LongLabel: Story = {
args: { children: "Requires the AI engine" },
};
/** A numeral, the other common use. */
export const Count: Story = { args: { children: "12", size: "sm" } };
@@ -0,0 +1,128 @@
/**
* The brand marks and the chrome controls that sit beside them. Grouped in one
* file because each is a handful of props and they are always seen together in
* the app's top-left corner.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button, Dropdown } from "@app/ui";
import { AppSwitchMenuItems } from "@app/components/shared/AppSwitch";
import { LogoIcon } from "@app/components/shared/LogoIcon";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
const meta: Meta = {
title: "Shared/Brand marks",
parameters: { layout: "padded" },
};
export default meta;
const Row = ({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) => (
<div
style={{
display: "flex",
alignItems: "center",
gap: "1.25rem",
padding: "0.75rem 0",
}}
>
<span
style={{
width: 190,
fontSize: "0.78rem",
color: "var(--c-text-muted)",
fontFamily: "var(--font-mono, monospace)",
}}
>
{label}
</span>
{children}
</div>
);
/** The wordmark, at rest and muted. Both swap asset with the theme, so switch
* the toolbar theme to check the dark pairing. */
export const WordmarkVariants: StoryObj = {
render: () => (
<div>
<Row label="default">
<Wordmark alt="Stirling PDF" style={{ height: 28 }} />
</Row>
<Row label="muted">
<Wordmark muted alt="Stirling PDF" style={{ height: 28 }} />
</Row>
<Row label="small">
<Wordmark alt="Stirling PDF" style={{ height: 18 }} />
</Row>
</div>
),
};
/** The icon-only mark, at the sizes the chrome uses it. */
export const LogoIconSizes: StoryObj = {
render: () => (
<div>
<Row label="16">
<LogoIcon alt="Stirling" style={{ height: 16 }} />
</Row>
<Row label="24">
<LogoIcon alt="Stirling" style={{ height: 24 }} />
</Row>
<Row label="40">
<LogoIcon alt="Stirling" style={{ height: 40 }} />
</Row>
</div>
),
};
/** The sidebar toggle. `mirrored` points it at the opposite edge, so the same
* glyph serves a left and a right rail. */
export const SidebarToggle: StoryObj = {
render: () => (
<div>
<Row label="default">
<SidebarToggleIcon />
</Row>
<Row label="mirrored">
<SidebarToggleIcon mirrored />
</Row>
<Row label="size 28">
<SidebarToggleIcon size={28} />
</Row>
<Row label="mirrored, size 28">
<SidebarToggleIcon mirrored size={28} />
</Row>
</div>
),
};
/** Switching between the editor and the processor. `AppSwitchMenuItems` is a
* fragment of dropdown items rather than a standalone menu, so it is shown in
* the Dropdown its callers mount it in. The app you are already in shows as
* current and is not offered as a destination. */
function AppSwitchDemo({ current }: { current: "editor" | "processor" }) {
return (
<Dropdown.Root defaultOpen>
<Dropdown.Trigger>
<Button variant="tertiary">Switch app</Button>
</Dropdown.Trigger>
<Dropdown.Menu>
<AppSwitchMenuItems current={current} onSwitch={() => {}} />
</Dropdown.Menu>
</Dropdown.Root>
);
}
export const AppSwitchFromEditor: StoryObj = {
render: () => <AppSwitchDemo current="editor" />,
};
/** The same menu seen from the processor. */
export const AppSwitchFromProcessor: StoryObj = {
render: () => <AppSwitchDemo current="processor" />,
};
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import CardSelector from "@app/components/shared/CardSelector";
import {
METHOD_OPTIONS,
type MethodOption,
type SplitMethod,
} from "@app/constants/splitConstants";
/**
* A stack of choice cards, each labelled from an i18n prefix + name pair.
* Driven here by the Split tool's real method options rather than invented
* keys, so the labels are the ones users actually see and the story does not
* introduce translation keys that have to be maintained.
*/
const meta: Meta<typeof CardSelector<SplitMethod, MethodOption>> = {
title: "Shared/CardSelector",
component: CardSelector,
parameters: { layout: "padded" },
args: { onSelect: () => {} },
};
export default meta;
type Story = StoryObj<typeof CardSelector<SplitMethod, MethodOption>>;
/** Every split method. */
export const Default: Story = { args: { options: METHOD_OPTIONS } };
/** A short list — two choices. */
export const FewOptions: Story = {
args: { options: METHOD_OPTIONS.slice(0, 2) },
};
/** A single choice, where the selector is really just a confirmation. */
export const SingleOption: Story = {
args: { options: METHOD_OPTIONS.slice(0, 1) },
};
/** Inert while the tool is busy or its endpoint is still resolving. */
export const Disabled: Story = {
args: { options: METHOD_OPTIONS, disabled: true },
};
/** Nothing available — e.g. every method needs an endpoint that is switched
* off. */
export const Empty: Story = { args: { options: [] } };
@@ -57,6 +57,20 @@ const CardSelector = <T, K extends CardOption<T>>({
radius="md"
w="100%"
h={"2.8rem"}
// A choice card is a control: without these it is a div with an
// onClick, so the option cannot be reached or chosen by keyboard.
// aria-disabled rather than removal keeps the option visible and
// explains why it is inert.
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled || undefined}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleOptionClick(option.value);
}
}}
style={{
cursor: disabled ? "default" : "pointer",
backgroundColor: "var(--mantine-color-gray-2)",
@@ -134,7 +134,11 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
zIndex={zIndex}
>
<Popover.Target>
{/* A real button: Popover.Target stamps aria-haspopup/aria-expanded on
its child, and those are only permitted on an actual control. */}
<Box
component="button"
type="button"
style={{
border:
"light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))",
@@ -142,6 +146,9 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
padding: "8px 12px",
backgroundColor:
"light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))",
color: "inherit",
textAlign: "left",
width: "100%",
opacity: disabled ? 0.6 : 1,
cursor: disabled ? "not-allowed" : "pointer",
minHeight: "36px",
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from "react";
import { useId, useState, useRef, useEffect } from "react";
import { PasswordInput, Group, Tooltip, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ActionIcon } from "@app/ui/ActionIcon";
@@ -32,6 +32,7 @@ export default function EditableSecretField({
error,
}: EditableSecretFieldProps) {
const { t } = useTranslation();
const fieldId = useId();
const [isEditing, setIsEditing] = useState(false);
const [tempValue, setTempValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
@@ -66,6 +67,7 @@ export default function EditableSecretField({
<div>
{label && (
<label
htmlFor={fieldId}
style={{
display: "block",
marginBottom: 4,
@@ -91,7 +93,13 @@ export default function EditableSecretField({
{isMasked && !isEditing ? (
// Masked value from backend: show display + Edit button
<Group gap="xs" align="flex-end">
<TextInput value="••••••••" disabled style={{ flex: 1 }} readOnly />
<TextInput
id={fieldId}
value="••••••••"
disabled
style={{ flex: 1 }}
readOnly
/>
<Tooltip label={t("editSecret")} withArrow>
<ActionIcon
variant="secondary"
@@ -110,6 +118,7 @@ export default function EditableSecretField({
) : isEditing ? (
// Edit mode: normal password input
<PasswordInput
id={fieldId}
ref={inputRef}
value={tempValue}
onChange={(e) => setTempValue(e.currentTarget.value)}
@@ -125,6 +134,7 @@ export default function EditableSecretField({
) : (
// Normal password input: empty or user typing
<PasswordInput
id={fieldId}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={placeholder}
@@ -73,7 +73,7 @@ const EncryptedPdfUnlockModal = ({
autoFocus
/>
{errorMessage ? (
<Text c="red" size="sm">
<Text c="var(--color-red-dark)" size="sm">
{errorMessage}
</Text>
) : null}
@@ -88,7 +88,7 @@ export default class ErrorBoundary extends React.Component<
margin: "0 auto",
}}
>
<Text size="lg" fw={500} c="red">
<Text size="lg" fw={500} c="var(--color-red-dark)">
Something went wrong
</Text>
{process.env.NODE_ENV === "development" && this.state.error && (
@@ -34,7 +34,12 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
return (
<Menu trigger="click" position="bottom" width="30rem">
<Menu.Target>
{/* Menu.Target stamps aria-haspopup/aria-expanded on its child; those are
only permitted once the element declares a control role. It stays a
div because it renders inside the workbench SegmentedControl's
<label>, which may not contain interactive content. */}
<div
role="button"
style={{ ...viewOptionStyle, cursor: "pointer", maxWidth: "100%" }}
>
{switchingTo === "viewer" ? (
@@ -91,6 +91,7 @@ const FileGrid = ({
{showSort && (
<Select
aria-label={t("fileManager.sortBy", "Sort files")}
data={[
{
value: "date",
@@ -189,6 +189,13 @@ const FilePickerModal = ({
checked={isSelected}
onChange={() => toggleFileSelection(fileId)}
onClick={(e) => e.stopPropagation()}
aria-label={t(
"fileUpload.selectFile",
"Select {{name}}",
{
name: file.name,
},
)}
/>
{/* Thumbnail */}
@@ -239,7 +246,7 @@ const FilePickerModal = ({
{/* Selection summary */}
{selectedFileIds.length > 0 && (
<Text size="sm" c="blue" ta="center">
<Text size="sm" c="var(--c-accent-text)" ta="center">
{selectedFileIds.length}{" "}
{t("fileManager.filesSelected", "files selected")}
</Text>
@@ -134,7 +134,7 @@
}
.slimTabUpload:hover:not(:disabled) {
color: var(--mantine-color-blue-filled);
color: var(--c-accent-text);
background: color-mix(in srgb, var(--mantine-color-blue-1) 35%, transparent);
}
@@ -404,6 +404,7 @@ export function FileSelectorPicker({
}}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-disabled={disabled || undefined}
>
<Text
size="sm"
@@ -129,14 +129,14 @@
}
.file-sidebar-drop-overlay-icon {
color: var(--mantine-color-blue-6, var(--c-primary)) !important;
color: var(--c-accent-text) !important;
font-size: 28px !important;
}
.file-sidebar-drop-overlay-text {
font-size: 13px;
font-weight: 600;
color: var(--mantine-color-blue-6, var(--c-primary));
color: var(--c-accent-text);
}
/* ---- Search row ---- */
@@ -605,7 +605,7 @@
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--mantine-color-blue-6, var(--c-primary));
background-color: var(--c-accent-text);
color: var(--c-text-on-primary);
font-size: 12px;
font-weight: 600;
@@ -183,7 +183,7 @@
}
.file-sidebar-file-item.selected .file-sidebar-file-name {
color: var(--c-accent-fg);
color: var(--c-accent-text);
}
.file-sidebar-file-meta-row {
@@ -208,7 +208,7 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--c-primary);
color: var(--c-accent-text);
}
/* ---- Folder membership tags ---- */
@@ -302,7 +302,7 @@
}
.file-sidebar-file-item.viewed .file-sidebar-file-name {
color: var(--c-success);
color: var(--color-green-dark);
}
.file-sidebar-file-item.viewed .file-sidebar-file-check {
@@ -321,7 +321,7 @@
/* Always show eye for the currently viewed file */
.file-sidebar-file-item.viewed .file-sidebar-eye-btn {
opacity: 1;
color: var(--c-success);
color: var(--color-green-dark);
}
.file-sidebar-eye-btn:hover {
@@ -74,3 +74,32 @@ export const WithDisabledAction: Story = {
],
},
};
/** Seated outside the card's edge rather than inside it. */
export const OutsidePosition: Story = {
args: { show: true, actions, position: "outside" },
};
/** An action hidden outright — dropped rather than greyed. */
export const WithHiddenAction: Story = {
args: {
show: true,
actions: [actions[0], { ...actions[1], hidden: true }, actions[2]],
},
};
/** Every action hidden: the menu renders nothing at all, so a card with no
* available actions gets no empty affordance. */
export const AllHidden: Story = {
args: { show: true, actions: actions.map((a) => ({ ...a, hidden: true })) },
};
/** A single action. */
export const SingleAction: Story = {
args: { show: true, actions: [actions[2]] },
};
/** Revealed by CSS hover rather than React state — hover the card. */
export const CssHoverVisibility: Story = {
args: { show: false, actions, visibility: "cssHover" },
};
@@ -27,7 +27,7 @@ const toneStyles: Record<
warning: {
background: "var(--mantine-color-orange-0)",
border: "var(--mantine-color-orange-3)",
text: "var(--mantine-color-orange-9)",
text: "var(--color-amber-dark)",
icon: "var(--mantine-color-orange-7)",
buttonColor: "orange",
},
@@ -397,7 +397,16 @@ export default function MobileUploadModal({
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
>
<QRCodeSVG value={mobileUrl} size={256} level="H" includeMargin />
<QRCodeSVG
value={mobileUrl}
size={256}
level="H"
includeMargin
title={t(
"mobileUpload.qrCodeTitle",
"QR code linking to the mobile upload page",
)}
/>
</Box>
{filesReceived > 0 && (
@@ -12,7 +12,7 @@
padding: 16px;
color: #ffffff;
font-weight: 600;
background: rgba(0, 0, 0, 0.55);
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.06);
@@ -175,7 +175,12 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
return (
<Menu trigger="click" position="bottom" width="40rem">
<Menu.Target>
{/* role="button" so the aria-haspopup/aria-expanded Menu.Target stamps on
this element are permitted. It stays a div because it renders inside
the workbench SegmentedControl's <label>, which may not contain
interactive content. */}
<div
role="button"
className="ph-no-capture"
style={{ ...viewOptionStyle, cursor: "pointer" }}
>
@@ -9,7 +9,10 @@ import {
import { MantineProvider } from "@mantine/core";
import { useIsomorphicEffect } from "@mantine/hooks";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { mantineTheme } from "@app/theme/mantineTheme";
import {
mantineTheme,
editorCssVariablesResolver,
} from "@app/theme/mantineTheme";
import { ToastProvider } from "@app/components/toast";
import ToastRenderer from "@app/components/toast/ToastRenderer";
import { ToastPortalBinder } from "@app/components/toast";
@@ -91,6 +94,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
<ThemeContext.Provider value={value}>
<MantineProvider
theme={mantineTheme}
cssVariablesResolver={editorCssVariablesResolver}
defaultColorScheme={colorScheme}
forceColorScheme={colorScheme}
>
@@ -34,7 +34,7 @@
height: 1.75rem;
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
color: var(--c-accent-text);
flex-shrink: 0;
}
@@ -79,15 +79,15 @@
var(--mantine-color-blue-filled) 18%,
transparent
);
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
color: var(--c-accent-text);
}
/* Dark mode: the subtle gray close button is too dim against the dark rail —
brighten it to a clearly-visible light grey (near-white on hover). */
[data-mantine-color-scheme="dark"] .sui-panelhdr__close {
color: var(--mantine-color-gray-4);
color: var(--c-text-subtle);
}
[data-mantine-color-scheme="dark"] .sui-panelhdr__close:hover {
color: var(--mantine-color-gray-2);
color: var(--c-text-subtle);
}
File diff suppressed because it is too large Load Diff
@@ -55,7 +55,7 @@
.workbench-bar-view-btn.active {
background-color: var(--c-primary-subtle);
color: var(--c-accent-fg);
color: var(--c-accent-text);
}
.workbench-bar-view-btn.workbench-bar-back-btn {
@@ -65,7 +65,7 @@
.workbench-bar-view-btn.workbench-bar-back-btn:hover {
background-color: color-mix(in srgb, var(--c-primary) 12%, transparent);
color: var(--c-accent-fg);
color: var(--c-accent-text);
}
.workbench-bar-view-btn svg {
@@ -175,6 +175,7 @@ export default function WorkbenchBar({
value={enforcingProgress}
striped
animated
aria-label={t("policy.enforcingTitle", "Enforcing policy…")}
/>
) : (
<Loader size="xs" />
@@ -18,7 +18,7 @@ const WARNING_ICON_STYLE: CSSProperties = {
fontSize: 36,
display: "block",
margin: "0 auto 8px",
color: "var(--mantine-color-blue-6)",
color: "var(--c-accent-text)",
};
const ZipWarningModal = ({
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useId, useState, useEffect } from "react";
import {
Paper,
Stack,
@@ -83,6 +83,15 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
desktopUpdateMode,
}) => {
const { t } = useTranslation();
// Each setting is a row of label text next to a bare control, so the controls
// are named by pointing at that text rather than by a <label> association.
const labelIds = useId();
const updateModeLabelId = `${labelIds}-update-mode`;
const viewerZoomLabelId = `${labelIds}-viewer-zoom`;
const hideToolsLabelId = `${labelIds}-hide-tools`;
const hideConversionsLabelId = `${labelIds}-hide-conversions`;
const autoUnzipLabelId = `${labelIds}-auto-unzip`;
const autoUnzipLimitLabelId = `${labelIds}-auto-unzip-limit`;
const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig();
const { setTheme, themeMode } = useTheme();
@@ -209,7 +218,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
icon="admin-panel-settings-rounded"
width="1.2rem"
height="1.2rem"
style={{ color: "var(--mantine-color-blue-6)" }}
style={{ color: "var(--c-accent-text)" }}
/>
<Text
fw={600}
@@ -248,7 +257,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
href="https://docs.stirlingpdf.com/Configuration/System%20and%20Security/"
target="_blank"
size="sm"
style={{ color: "var(--mantine-color-blue-6)" }}
>
{t(
"settings.general.enableFeatures.learnMore",
@@ -305,7 +313,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Text>
</Text>
{mismatchVersion && (
<Text size="sm" c="red" mt={4}>
<Text size="sm" c="var(--color-red-dark)" mt={4}>
{t(
"settings.general.updates.versionMismatch",
"Warning: A mismatch has been detected between the client version and the AppConfig version. Using different versions can lead to compatibility issues, errors, and security risks. Please ensure that server and client are using the same version.",
@@ -336,7 +344,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
"Latest Version",
)}
:{" "}
<Text component="span" fw={500} c="blue">
<Text component="span" fw={500} c="var(--c-accent-text)">
{updateSummary.latest_version}
</Text>
</Text>
@@ -391,7 +399,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
{desktopUpdateMode && (
<Stack gap="xs">
<Group gap="xs" align="center">
<Text fw={600} size="sm">
<Text id={updateModeLabelId} fw={600} size="sm">
{t(
"settings.general.updates.updateBehavior",
"Update behavior",
@@ -422,6 +430,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
)}
</Text>
<Select
aria-labelledby={updateModeLabelId}
disabled={desktopUpdateMode.locked}
value={desktopUpdateMode.mode}
onChange={(value) => {
@@ -622,7 +631,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
<Text id={viewerZoomLabelId} fw={500} size="sm">
{t("settings.general.defaultViewerZoom", "Default reader zoom")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
@@ -633,6 +642,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Text>
</div>
<Select
aria-labelledby={viewerZoomLabelId}
value={preferences.defaultViewerZoom}
onChange={(val: string | null) => {
if (val)
@@ -677,7 +687,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
<Text id={hideToolsLabelId} fw={500} size="sm">
{t(
"settings.general.hideUnavailableTools",
"Hide unavailable tools",
@@ -691,6 +701,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Text>
</div>
<Switch
aria-labelledby={hideToolsLabelId}
checked={preferences.hideUnavailableTools}
onChange={(event) =>
updatePreference(
@@ -708,7 +719,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
<Text id={hideConversionsLabelId} fw={500} size="sm">
{t(
"settings.general.hideUnavailableConversions",
"Hide unavailable conversions",
@@ -722,6 +733,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Text>
</div>
<Switch
aria-labelledby={hideConversionsLabelId}
checked={preferences.hideUnavailableConversions}
onChange={(event) =>
updatePreference(
@@ -749,7 +761,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
<Text id={autoUnzipLabelId} fw={500} size="sm">
{t("settings.general.autoUnzip", "Auto-unzip API responses")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
@@ -760,6 +772,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Text>
</div>
<Switch
aria-labelledby={autoUnzipLabelId}
checked={preferences.autoUnzip}
onChange={(event) =>
updatePreference("autoUnzip", event.currentTarget.checked)
@@ -786,7 +799,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
<Text id={autoUnzipLimitLabelId} fw={500} size="sm">
{t(
"settings.general.autoUnzipFileLimit",
"Auto-unzip file limit",
@@ -800,6 +813,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Text>
</div>
<NumberInput
aria-labelledby={autoUnzipLimitLabelId}
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
@@ -13,7 +13,7 @@ const Overview: React.FC = () => {
return (
<Stack gap="xs" mb="md">
<Text fw={600} size="md" c="blue">
<Text fw={600} size="md" c="var(--c-accent-text)">
{title}
</Text>
<Stack gap="xs" pl="md">
@@ -270,7 +270,7 @@ export default function ProviderCard({
href={provider.documentationUrl}
target="_blank"
size="xs"
c="blue"
c="var(--c-accent-text)"
>
{t(
"admin.settings.connections.documentation",
@@ -33,6 +33,23 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
}) => {
if (!file) return null;
// A thumbnail that takes a click is a control; without these it is a div, so
// the file cannot be opened by keyboard. Only applied when a handler was
// given — a decorative thumbnail should not take a tab stop.
const interactive = onClick
? {
role: "button",
tabIndex: 0,
onClick,
onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
},
}
: {};
const containerStyle: React.CSSProperties = {
position: "relative",
cursor: onClick ? "pointer" : "default",
@@ -47,7 +64,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
if (thumbnail && !isEncrypted) {
return (
<Box style={containerStyle} onClick={onClick}>
<Box style={containerStyle} {...interactive}>
<PrivateContent>
<img
src={thumbnail}
@@ -77,7 +94,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
if (isEncrypted) {
return (
<Box style={containerStyle} onClick={onClick}>
<Box style={containerStyle} {...interactive}>
<div
style={{
display: "flex",
@@ -100,7 +117,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
fontWeight: 700,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--mantine-color-red-6)",
color: "var(--color-red-dark)",
background: "rgba(220,38,38,0.1)",
padding: "2px 8px",
borderRadius: "6px",
@@ -116,7 +133,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
if (isLoading) {
return (
<Box style={containerStyle} onClick={onClick}>
<Box style={containerStyle} {...interactive}>
<Stack
align="center"
justify="center"
@@ -136,7 +153,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
const ext = detectFileExtension(file.name ?? "").toUpperCase();
return (
<Box style={containerStyle} onClick={onClick}>
<Box style={containerStyle} {...interactive}>
<Center
style={{
width: "100%",
@@ -51,7 +51,10 @@ const StepWrapper: React.FC<StepWrapperProps> = ({
: isCompleted
? "var(--mantine-color-gray-light)"
: "transparent",
opacity: !isActive && !isCompleted ? 0.6 : 1,
// Pending steps recede via a muted text colour rather than opacity,
// which would drag their labels below the contrast floor.
color:
!isActive && !isCompleted ? "var(--c-text-muted)" : "var(--c-text)",
}}
>
<Group gap="sm" mb={isActive ? "md" : 0}>
@@ -1,3 +1,4 @@
import { useId } from "react";
import { Slider, Text, Group, NumberInput } from "@mantine/core";
interface Props {
@@ -21,9 +22,10 @@ export default function SliderWithInput({
step = 1,
suffix = "%",
}: Props) {
const labelId = useId();
return (
<div>
<Text size="sm" fw={500} mb={8}>
<Text id={labelId} size="sm" fw={500} mb={8}>
{label}
</Text>
<Group gap="md" align="center">
@@ -35,6 +37,9 @@ export default function SliderWithInput({
value={value}
onChange={onChange}
disabled={disabled}
// Mantine's slider thumb is a div, not an input, so the heading
// above cannot name it through a <label> association.
thumbLabel={label}
/>
</div>
<NumberInput
@@ -46,6 +51,7 @@ export default function SliderWithInput({
disabled={disabled}
suffix={suffix}
style={{ width: 90 }}
aria-labelledby={labelId}
/>
</Group>
</div>
@@ -129,6 +129,13 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
onChange={setPenColor}
format="hex"
size="xs"
// The saturation area and hue bar are role="slider" divs; these are
// their only accessible names.
saturationLabel={t(
"colorPicker.saturation",
"Saturation and brightness",
)}
hueLabel={t("colorPicker.hue", "Hue")}
/>
</div>
<div style={{ flex: 2 }}>
@@ -144,6 +151,12 @@ export const DrawSignatureCanvas: React.FC<DrawSignatureCanvasProps> = ({
max={10}
step={1}
disabled={disabled}
// The thumb is a div, so the heading above cannot name it.
thumbLabel={t(
"certSign.collab.signRequest.penSize",
"Pen Size: {{size}}px",
{ size: penSize },
)}
marks={[
{ value: 1, label: "1" },
{ value: 5, label: "5" },
@@ -118,6 +118,12 @@ export const TypeSignatureText: React.FC<TypeSignatureTextProps> = ({
max={80}
step={2}
disabled={disabled}
// The thumb is a div, so the heading above cannot name it.
thumbLabel={t(
"certSign.collab.signRequest.fontSize",
"Font Size: {{size}}px",
{ size: fontSize },
)}
marks={[
{ value: 20, label: "20" },
{ value: 50, label: "50" },
@@ -130,7 +136,18 @@ export const TypeSignatureText: React.FC<TypeSignatureTextProps> = ({
<Text size="sm" mb={4}>
{t("certSign.collab.signRequest.textColor", "Text Color")}
</Text>
<ColorPicker value={color} onChange={onColorChange} format="hex" />
<ColorPicker
value={color}
onChange={onColorChange}
format="hex"
// The saturation area and hue bar are role="slider" divs; these are
// their only accessible names.
saturationLabel={t(
"colorPicker.saturation",
"Saturation and brightness",
)}
hueLabel={t("colorPicker.hue", "Hue")}
/>
</div>
{/* Preview */}
@@ -124,7 +124,7 @@ export const UploadSignatureImage: React.FC<UploadSignatureImageProps> = ({
)}
{error && (
<Text size="xs" c="red">
<Text size="xs" c="var(--color-red-dark)">
{error}
</Text>
)}
@@ -0,0 +1,67 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import AddPageNumbersAutomationSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
import {
defaultParameters,
type AddPageNumbersParameters,
} from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters";
/** Page-number settings in the form the pipeline builder embeds. */
const meta: Meta<typeof AddPageNumbersAutomationSettings> = {
title: "Tools/AddPageNumbers/AddPageNumbersAutomationSettings",
component: AddPageNumbersAutomationSettings,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof AddPageNumbersAutomationSettings>;
function Demo({
overrides,
disabled,
}: {
overrides?: Partial<AddPageNumbersParameters>;
disabled?: boolean;
}) {
const [parameters, setParameters] = useState<AddPageNumbersParameters>({
...defaultParameters,
...overrides,
});
return (
<AddPageNumbersAutomationSettings
parameters={parameters}
onParameterChange={(key, value) =>
setParameters((prev) => ({ ...prev, [key]: value }))
}
disabled={disabled}
/>
);
}
/** Defaults: Times 12pt, starting at 1, no custom text. */
export const Default: Story = { render: () => <Demo /> };
/** A custom template around the number. */
export const CustomText: Story = {
render: () => <Demo overrides={{ customText: "Page {n} of {total}" }} />,
};
/** Numbering only part of the document, starting mid-way. */
export const PageRangeAndOffset: Story = {
render: () => (
<Demo overrides={{ pagesToNumber: "3-12,15", startingNumber: 7 }} />
),
};
/** Zero-padded numbers, for documents filed by page. */
export const ZeroPadded: Story = {
render: () => <Demo overrides={{ zeroPad: 3 }} />,
};
/** A different face and a larger size. */
export const CourierLarge: Story = {
render: () => <Demo overrides={{ fontType: "Courier", fontSize: 24 }} />,
};
/** Inert. */
export const Disabled: Story = { render: () => <Demo disabled /> };
@@ -126,6 +126,6 @@
/* Preview disclaimer */
.previewDisclaimer {
margin-top: 8px;
opacity: 0.7;
color: var(--c-text-muted);
font-size: 12px;
}
@@ -0,0 +1,71 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import AddStampAutomationSettings from "@app/components/tools/addStamp/AddStampAutomationSettings";
import {
defaultParameters,
type AddStampParameters,
} from "@app/components/tools/addStamp/useAddStampParameters";
/** Stamp settings in the form the pipeline builder embeds. */
const meta: Meta<typeof AddStampAutomationSettings> = {
title: "Tools/AddStamp/AddStampAutomationSettings",
component: AddStampAutomationSettings,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof AddStampAutomationSettings>;
function Demo({
overrides,
disabled,
}: {
overrides?: Partial<AddStampParameters>;
disabled?: boolean;
}) {
const [parameters, setParameters] = useState<AddStampParameters>({
...defaultParameters,
...overrides,
});
return (
<AddStampAutomationSettings
parameters={parameters}
onParameterChange={(key, value) =>
setParameters((prev) => ({ ...prev, [key]: value }))
}
disabled={disabled}
/>
);
}
/** Defaults — no stamp text entered yet. */
export const Default: Story = { render: () => <Demo /> };
/** A typical text stamp. */
export const TextStamp: Story = {
render: () => <Demo overrides={{ stampText: "CONFIDENTIAL" }} />,
};
/** Rotated and part-transparent, the usual watermark-style stamp. */
export const RotatedTranslucent: Story = {
render: () => (
<Demo overrides={{ stampText: "DRAFT", rotation: 45, opacity: 30 }} />
),
};
/** A non-Roman alphabet, which selects a different embedded face. */
export const JapaneseAlphabet: Story = {
render: () => (
<Demo overrides={{ stampText: "社外秘", alphabet: "japanese" }} />
),
};
/** Applied to a page range rather than the whole document. */
export const PageRange: Story = {
render: () => (
<Demo overrides={{ stampText: "EXHIBIT A", pageNumbers: "1,4-9" }} />
),
};
/** Inert. */
export const Disabled: Story = { render: () => <Demo disabled /> };
@@ -119,7 +119,7 @@
/* Preview disclaimer */
.previewDisclaimer {
margin-top: 8px;
opacity: 0.7;
color: var(--c-text-muted);
font-size: 12px;
}
@@ -393,6 +393,7 @@ export default function StampPreview({
<div
className={`${styles.stampItem} ${styles.stampItemGridMode}`}
style={style.item as React.CSSProperties}
data-user-content-preview=""
>
{(parameters.stampText || "").split("\n").map((line, idx) => (
<span
@@ -1,3 +1,4 @@
import { useId } from "react";
import { Stack, Text, NumberInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
@@ -17,15 +18,20 @@ const WatermarkStyleSettings = ({
disabled = false,
}: WatermarkStyleSettingsProps) => {
const { t } = useTranslation();
const rotationLabelId = useId();
const opacityLabelId = useId();
const widthLabelId = useId();
const heightLabelId = useId();
return (
<Stack gap="md">
{/* Appearance Settings */}
<Stack gap="sm">
<Text size="sm" fw={500}>
<Text id={rotationLabelId} size="sm" fw={500}>
{t("watermark.settings.rotation", "Rotation (degrees)")}
</Text>
<NumberInput
aria-labelledby={rotationLabelId}
value={parameters.rotation}
onChange={(value) =>
onParameterChange(
@@ -40,10 +46,11 @@ const WatermarkStyleSettings = ({
disabled={disabled}
/>
<Text size="sm" fw={500}>
<Text id={opacityLabelId} size="sm" fw={500}>
{t("watermark.settings.opacity", "Opacity (%)")}
</Text>
<NumberInput
aria-labelledby={opacityLabelId}
value={parameters.opacity}
onChange={(value) =>
onParameterChange(
@@ -61,10 +68,11 @@ const WatermarkStyleSettings = ({
{/* Spacing Settings */}
<Stack gap="sm">
<Text size="sm" fw={500}>
<Text id={widthLabelId} size="sm" fw={500}>
{t("watermark.settings.spacing.width", "Width Spacing")}
</Text>
<NumberInput
aria-labelledby={widthLabelId}
value={parameters.widthSpacer}
onChange={(value) =>
onParameterChange(
@@ -79,10 +87,11 @@ const WatermarkStyleSettings = ({
disabled={disabled}
/>
<Text size="sm" fw={500}>
<Text id={heightLabelId} size="sm" fw={500}>
{t("watermark.settings.spacing.height", "Height Spacing")}
</Text>
<NumberInput
aria-labelledby={heightLabelId}
value={parameters.heightSpacer}
onChange={(value) =>
onParameterChange(
@@ -1,3 +1,4 @@
import { useId } from "react";
import { Stack, Text, Select, ColorInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
@@ -19,14 +20,17 @@ const WatermarkTextStyle = ({
disabled = false,
}: WatermarkTextStyleProps) => {
const { t } = useTranslation();
const colorLabelId = useId();
const alphabetLabelId = useId();
return (
<Stack gap="sm">
<Stack gap="xs">
<Text size="xs" fw={500}>
<Text id={colorLabelId} size="xs" fw={500}>
{t("watermark.settings.color", "Colour")}
</Text>
<ColorInput
aria-labelledby={colorLabelId}
value={parameters.customColor}
onChange={(value) => onParameterChange("customColor", value)}
disabled={disabled}
@@ -39,10 +43,11 @@ const WatermarkTextStyle = ({
</Stack>
<Stack gap="xs">
<Text size="xs" fw={500}>
<Text id={alphabetLabelId} size="xs" fw={500}>
{t("watermark.settings.alphabet", "Alphabet")}
</Text>
<Select
aria-labelledby={alphabetLabelId}
value={parameters.alphabet}
onChange={(value) => value && onParameterChange("alphabet", value)}
data={alphabetOptions}
@@ -0,0 +1,62 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import AutoRotateAutomationSettings from "@app/components/tools/autoRotate/AutoRotateAutomationSettings";
import {
defaultParameters,
type AutoRotateParameters,
} from "@app/hooks/tools/autoRotate/useAutoRotateParameters";
/** The same detection controls as the tool panel, in the form the pipeline
* builder embeds — a plain parameters object with a change callback rather
* than the tool's own hook. */
const meta: Meta<typeof AutoRotateAutomationSettings> = {
title: "Tools/AutoRotate/AutoRotateAutomationSettings",
component: AutoRotateAutomationSettings,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof AutoRotateAutomationSettings>;
function Demo({
overrides,
disabled,
}: {
overrides?: Partial<AutoRotateParameters>;
disabled?: boolean;
}) {
const [parameters, setParameters] = useState<AutoRotateParameters>({
...defaultParameters,
...overrides,
});
return (
<AutoRotateAutomationSettings
parameters={parameters}
onParameterChange={(key, value) =>
setParameters((prev) => ({ ...prev, [key]: value }))
}
disabled={disabled}
/>
);
}
/** Defaults. */
export const Default: Story = { render: () => <Demo /> };
/** Forced to text-direction detection. */
export const TextOnly: Story = {
render: () => <Demo overrides={{ detectionMode: "text" }} />,
};
/** Forced to OCR orientation detection. */
export const OsdOnly: Story = {
render: () => <Demo overrides={{ detectionMode: "osd" }} />,
};
/** A high confidence floor. */
export const StrictConfidence: Story = {
render: () => <Demo overrides={{ confidenceThreshold: 85 }} />,
};
/** Inert. */
export const Disabled: Story = { render: () => <Demo disabled /> };
@@ -0,0 +1,109 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import AutoRotateReport from "@app/components/tools/autoRotate/AutoRotateReport";
import type {
AutoRotatePageResult,
AutoRotateReport as ReportData,
} from "@app/hooks/tools/autoRotate/useAutoRotateOperation";
const page = (
pageNumber: number,
over: Partial<AutoRotatePageResult> = {},
): AutoRotatePageResult => ({
pageNumber,
currentRotation: 0,
correction: 0,
confidence: 96,
method: "text",
apply: true,
...over,
});
const report = (pages: AutoRotatePageResult[]): ReportData => ({
pages,
totalPages: pages.length,
pagesToRotate: pages.filter((p) => p.apply && p.correction !== 0).length,
detectedByText: pages.filter((p) => p.method === "text").length,
detectedByOsd: pages.filter((p) => p.method === "osd").length,
inferred: pages.filter((p) => p.method === "inferred").length,
undetected: pages.filter((p) => p.confidence === null).length,
});
/** What auto-rotate decided, per page: how each page's orientation was
* detected, how confident that was, and whether a correction will be applied. */
const meta: Meta<typeof AutoRotateReport> = {
title: "Tools/AutoRotate/AutoRotateReport",
component: AutoRotateReport,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof AutoRotateReport>;
/** A mixed document: text detection, an OCR fallback, and a page left alone. */
export const Default: Story = {
args: {
reports: [
{
fileName: "contract-2026.pdf",
report: report([
page(1),
page(2, { correction: 90, currentRotation: 270 }),
page(3, { method: "osd", confidence: 12.4, correction: 180 }),
page(4, { confidence: null, apply: false, note: "No text found" }),
]),
},
],
},
};
/** Nothing needed correcting. */
export const NothingToRotate: Story = {
args: {
reports: [
{ fileName: "already-upright.pdf", report: report([page(1), page(2)]) },
],
},
};
/** Several files in one run — each keeps its own breakdown. */
export const MultipleFiles: Story = {
args: {
reports: [
{
fileName: "scans-batch-a.pdf",
report: report([page(1, { correction: 90 }), page(2)]),
},
{
fileName: "scans-batch-b.pdf",
report: report([
page(1, { method: "osd", confidence: 8.1, correction: 270 }),
page(2, { confidence: null, apply: false }),
]),
},
],
},
};
/** A long document, to check the per-page list stays readable as it grows. */
export const LongDocument: Story = {
args: {
reports: [
{
fileName: "deposition-transcript.pdf",
report: report(
Array.from({ length: 40 }, (_, i) =>
page(i + 1, {
correction: i % 3 === 0 ? 90 : 0,
method: i % 5 === 0 ? "osd" : "text",
confidence: i % 7 === 0 ? null : 70 + (i % 30),
apply: i % 7 !== 0,
}),
),
),
},
],
},
};
/** No files analysed yet. */
export const Empty: Story = { args: { reports: [] } };
@@ -118,7 +118,16 @@ const AutoRotateReport = ({ reports }: AutoRotateReportProps) => {
{/* A per-page list rather than a table: the tool panel is too narrow
for five columns, which truncated the badges and wrapped notes. */}
<ScrollArea.Autosize mah={300}>
{/* The per-page list scrolls but holds no control of its own, so it
needs a tab stop to be reachable without a mouse. */}
<ScrollArea.Autosize
mah={300}
viewportProps={{
tabIndex: 0,
role: "group",
"aria-label": t("autoRotate.report.pages", "Per-page results"),
}}
>
<Stack gap={0}>
{report.pages.map((page, index) => {
const confidence = confidenceValue(page);
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import AutoRotateSettings from "@app/components/tools/autoRotate/AutoRotateSettings";
import {
defaultParameters,
validateAutoRotateParameters,
type AutoRotateParameters,
} from "@app/hooks/tools/autoRotate/useAutoRotateParameters";
import { useStoryParameters } from "@app/components/tools/shared/storyParameters";
/** How pages are detected before rotation: `auto` tries embedded text first
* and falls back to OCR orientation detection; the other modes force one. */
const meta: Meta<typeof AutoRotateSettings> = {
title: "Tools/AutoRotate/AutoRotateSettings",
component: AutoRotateSettings,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof AutoRotateSettings>;
function Demo({
overrides,
disabled,
}: {
overrides?: Partial<AutoRotateParameters>;
disabled?: boolean;
}) {
const parameters = useStoryParameters<AutoRotateParameters>(
{ ...defaultParameters, ...overrides },
{ endpointName: "auto-rotate", validate: validateAutoRotateParameters },
);
return <AutoRotateSettings parameters={parameters} disabled={disabled} />;
}
/** Defaults: automatic detection, inference on. */
export const Default: Story = { render: () => <Demo /> };
/** Text-direction detection only — no OCR pass. */
export const TextOnly: Story = {
render: () => <Demo overrides={{ detectionMode: "text" }} />,
};
/** OCR orientation detection only, where the pages carry no extractable text. */
export const OsdOnly: Story = {
render: () => <Demo overrides={{ detectionMode: "osd" }} />,
};
/** A high confidence floor — only strongly-detected pages get corrected. */
export const StrictConfidence: Story = {
render: () => <Demo overrides={{ confidenceThreshold: 85 }} />,
};
/** Undetected pages left alone rather than taking the document consensus. */
export const InferenceOff: Story = {
render: () => <Demo overrides={{ inferUndetected: false }} />,
};
/** Inert while the tool is running. */
export const Disabled: Story = { render: () => <Demo disabled /> };
@@ -104,6 +104,11 @@ export default function AutomationImportModal({
}
};
const dropzoneLabel = t(
"automate.importModal.dropzoneAriaLabel",
"Drop an automation JSON file here",
);
const formatLabel =
parsed?.format === "automate"
? t("automate.importModal.detectedAutomation", "Automate JSON")
@@ -133,10 +138,10 @@ export default function AutomationImportModal({
accept={["application/json", "text/plain"]}
multiple={false}
maxSize={10 * 1024 * 1024}
aria-label={t(
"automate.importModal.dropzoneAriaLabel",
"Drop an automation JSON file here",
)}
aria-label={dropzoneLabel}
// Dropzone's own aria-label lands on the wrapper; the hidden file
// input it renders needs naming separately.
inputProps={{ "aria-label": dropzoneLabel }}
>
<Group
gap="md"
@@ -207,7 +212,7 @@ export default function AutomationImportModal({
})}
</Text>
{parsed.unresolvedOperations.length > 0 && (
<Text size="xs" c="orange">
<Text size="xs" c="var(--color-amber-dark)">
{t("automate.importModal.unresolved", "Unmapped: {{ops}}", {
ops: parsed.unresolvedOperations.join(", "),
})}
@@ -183,7 +183,11 @@ export default function AutomationRun({
<Text size="sm" mb="xs">
Progress: {currentStepIndex + 1}/{executionSteps.length}
</Text>
<Progress value={getProgress()} size="lg" />
<Progress
value={getProgress()}
size="lg"
aria-label={t("automate.runProgress", "Automation progress")}
/>
</div>
)}
@@ -216,7 +220,7 @@ export default function AutomationRun({
{step.name}
</Text>
{step.error && (
<Text size="xs" c="red" mt="xs">
<Text size="xs" c="var(--color-red-dark)" mt="xs">
{step.error}
</Text>
)}
@@ -92,7 +92,16 @@ export default function IconSelector({
return (
<Tooltip key={option.value} label={option.label}>
<Box
role="button"
tabIndex={0}
aria-label={option.label}
onClick={() => handleIconSelect(option.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleIconSelect(option.value);
}
}}
style={{
display: "flex",
alignItems: "center",
@@ -70,7 +70,7 @@ const BookletImpositionSettings = ({
{/* Manual Duplex Pass Selection - only show when double-sided is OFF */}
{!parameters.doubleSided && (
<Stack gap="xs" ml="lg">
<Text size="sm" fw={500} c="orange">
<Text size="sm" fw={500} c="var(--color-amber-dark)">
{t("bookletImposition.manualDuplex.title", "Manual Duplex Mode")}
</Text>
<Text size="xs" c="dimmed">
@@ -97,7 +97,7 @@ const BookletImpositionSettings = ({
disabled={disabled}
/>
<Text size="xs" c="blue" fs="italic">
<Text size="xs" c="var(--c-accent-text)" fs="italic">
{parameters.duplexPass === "FIRST"
? t(
"bookletImposition.duplexPass.firstInstructions",
@@ -249,7 +249,7 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
fontSize="small"
style={{ color: "var(--mantine-color-green-6)" }}
/>
<Text size="sm" c="green">
<Text size="sm" c="var(--color-green-dark)">
{t(
"certSign.collab.signRequest.certModal.certValidUntil",
"Certificate valid until {{date}}",
@@ -271,7 +271,7 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
fontSize="small"
style={{ color: "var(--mantine-color-red-6)" }}
/>
<Text size="sm" c="red">
<Text size="sm" c="var(--color-red-dark)">
{t(
"certSign.collab.signRequest.certModal.certInvalid",
"Certificate invalid: {{error}}",
@@ -318,7 +318,7 @@ const SignRequestPanel = ({ data }: SignRequestPanelProps) => {
fullWidth
style={{
backgroundColor: "var(--c-surface-raised)",
color: "var(--c-primary)",
color: "var(--c-accent-text)",
border: "1px solid var(--c-border)",
}}
>
@@ -135,7 +135,7 @@ export const AddSignaturesStep: React.FC<AddSignaturesStepProps> = ({
</Paper>
{placementMode && (
<Text size="xs" c="blue" ta="center">
<Text size="xs" c="var(--c-accent-text)" ta="center">
{t(
"certSign.collab.signRequest.steps.clickMultipleTimes",
"Click on the PDF multiple times to place signatures. Drag any signature to move or resize it.",
@@ -188,7 +188,7 @@ const ComparePixelWorkbenchView = ({
{result.warnings.length > 0 && (
<Stack gap={4}>
{result.warnings.map((w, i) => (
<Text key={i} size="xs" c="yellow.7">
<Text key={i} size="xs" c="var(--color-amber-dark)">
{w}
</Text>
))}
@@ -116,6 +116,7 @@ const CompressSettings = ({
style={{ flex: 1 }}
/>
<Select
aria-label={t("compress.settings.desiredSizeUnit", "Size unit")}
value={parameters.fileSizeUnit}
onChange={(value) => {
// Prevent deselection - if value is null/undefined, keep the current value
@@ -244,6 +245,8 @@ const CompressSettings = ({
}}
disabled={disabled || imageMagickAvailable === false}
label={null}
// The thumb is a div, so the heading above cannot name it.
thumbLabel={t("compress.lineArt.detailLevel", "Detail level")}
marks={[
{ value: 1 },
{ value: 2 },
@@ -1,3 +1,4 @@
import { useId } from "react";
import { Stack, Text, NumberInput, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
@@ -17,6 +18,7 @@ const ConvertFromEmailSettings = ({
disabled = false,
}: ConvertFromEmailSettingsProps) => {
const { t } = useTranslation();
const maxSizeLabelId = useId();
return (
<Stack gap="sm" data-testid="email-settings">
@@ -39,10 +41,11 @@ const ConvertFromEmailSettings = ({
{parameters.emailOptions.includeAttachments && (
<Stack gap="xs">
<Text size="xs" fw={500}>
<Text id={maxSizeLabelId} size="xs" fw={500}>
{t("convert.maxAttachmentSize", "Maximum attachment size (MB)")}:
</Text>
<NumberInput
aria-labelledby={maxSizeLabelId}
value={parameters.emailOptions.maxAttachmentSizeMB}
onChange={(value) =>
onParameterChange("emailOptions", {
@@ -1,3 +1,4 @@
import { useId } from "react";
import { Stack, Text, NumberInput, Slider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
@@ -17,6 +18,8 @@ const ConvertFromWebSettings = ({
disabled = false,
}: ConvertFromWebSettingsProps) => {
const { t } = useTranslation();
const zoomLabelId = useId();
const zoomLabel = t("convert.zoomLevel", "Zoom Level");
return (
<Stack gap="sm" data-testid="web-settings">
@@ -25,10 +28,11 @@ const ConvertFromWebSettings = ({
</Text>
<Stack gap="xs">
<Text size="xs" fw={500}>
{t("convert.zoomLevel", "Zoom Level")}:
<Text id={zoomLabelId} size="xs" fw={500}>
{zoomLabel}:
</Text>
<NumberInput
aria-labelledby={zoomLabelId}
value={parameters.htmlOptions.zoomLevel}
onChange={(value) =>
onParameterChange("htmlOptions", {
@@ -55,6 +59,8 @@ const ConvertFromWebSettings = ({
step={0.1}
disabled={disabled}
data-testid="zoom-level-slider"
// The thumb is a div, so the heading above cannot name it.
thumbLabel={zoomLabel}
/>
</Stack>
</Stack>
@@ -1,3 +1,4 @@
import { useId } from "react";
import { Stack, Text, Select, Alert, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
@@ -24,6 +25,7 @@ const ConvertToPdfaSettings = ({
const { t } = useTranslation();
const { hasDigitalSignatures, isChecking } =
usePdfSignatureDetection(selectedFiles);
const outputFormatLabelId = useId();
const pdfaFormatOptions = [
{ value: "pdfa-1", label: "PDF/A-1b" },
@@ -49,10 +51,11 @@ const ConvertToPdfaSettings = ({
)}
<Stack gap="xs">
<Text size="xs" fw={500}>
<Text id={outputFormatLabelId} size="xs" fw={500}>
{t("convert.outputFormat", "Output Format")}:
</Text>
<Select
aria-labelledby={outputFormatLabelId}
value={parameters.pdfaOptions.outputFormat}
onChange={(value) =>
onParameterChange("pdfaOptions", {
@@ -93,7 +93,7 @@
}
.languagePickerLink {
color: var(--c-accent-fg);
color: var(--c-accent-text);
text-decoration: underline;
cursor: pointer;
}
@@ -155,20 +155,21 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
"Looking for additional languages?",
)}
</Text>
{/* A real anchor: it was styled as a link and opened one, but as a
Text with an onClick it could not be reached by keyboard and gave
no target cue on hover. */}
<Text
component="a"
href="https://docs.stirlingpdf.com/Configuration/OCR"
target="_blank"
rel="noopener noreferrer"
size="xs"
style={{
color: "var(--c-primary)",
color: "var(--c-accent-text)",
cursor: "pointer",
textDecoration: "underline",
textAlign: "center",
}}
onClick={() =>
window.open(
"https://docs.stirlingpdf.com/Configuration/OCR",
"_blank",
)
}
>
{t("ocr.languagePicker.viewSetupGuide", "View setup guide →")}
</Text>
@@ -167,7 +167,7 @@ const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => {
{/* Warnings */}
{analysis.warnings.length > 0 && (
<Box>
<Text size="xs" c="orange" fw={500}>
<Text size="xs" c="var(--color-amber-dark)" fw={500}>
{t("pdfTextEditor.fontAnalysis.warnings", "Warnings")}:
</Text>
<List size="xs" spacing={2} withPadding>
@@ -183,7 +183,7 @@ const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => {
{/* Suggestions */}
{analysis.suggestions.length > 0 && (
<Box>
<Text size="xs" c="blue" fw={500}>
<Text size="xs" c="var(--c-accent-text)" fw={500}>
{t("pdfTextEditor.fontAnalysis.suggestions", "Notes")}:
</Text>
<List size="xs" spacing={2} withPadding>
@@ -1406,6 +1406,17 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
clearSelection();
};
// Clicking the page backdrop deselects, but that is mouse-only: without this
// a keyboard user has no way out of a selection. The backdrop itself stays a
// plain surface — it is a canvas, not a control.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") handleBackgroundClick();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
});
const handleSelectionInteraction = useCallback(
(groupId: string, groupIndex: number, event: React.MouseEvent): boolean => {
const multiSelect = event.metaKey || event.ctrlKey;
@@ -1683,7 +1694,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
>
<Stack align="center" gap="md" style={{ pointerEvents: "none" }}>
<UploadFileIcon
sx={{ fontSize: 48, color: "var(--mantine-color-blue-5)" }}
sx={{ fontSize: 48, color: "var(--c-accent-text)" }}
/>
<Text size="lg" fw={600}>
{t("pdfTextEditor.empty.title", "No document loaded")}
@@ -1741,6 +1752,10 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
value={conversionProgress?.percent || 0}
size="lg"
radius="md"
aria-label={t(
"pdfTextEditor.converting",
"Converting PDF to editable format...",
)}
/>
</Stack>
</Card>
@@ -1,3 +1,4 @@
import { useId } from "react";
import {
Stack,
Text,
@@ -25,6 +26,11 @@ const RemoveBlanksSettings = ({
disabled = false,
}: RemoveBlanksSettingsProps) => {
const { t } = useTranslation();
const whitePercentLabelId = useId();
const whitePercentLabel = t(
"removeBlanks.whitePercent.label",
"White Percent",
);
return (
<Stack gap="lg" mt="md">
@@ -46,11 +52,12 @@ const RemoveBlanksSettings = ({
</Stack>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("removeBlanks.whitePercent.label", "White Percent")}
<Text id={whitePercentLabelId} size="sm" fw={500}>
{whitePercentLabel}
</Text>
<Group align="center">
<NumberInput
aria-labelledby={whitePercentLabelId}
value={parameters.whitePercent}
onChange={(v) =>
onParameterChange("whitePercent", typeof v === "number" ? v : 0.1)
@@ -71,6 +78,8 @@ const RemoveBlanksSettings = ({
step={0.1}
style={{ flex: 1 }}
disabled={disabled}
// The thumb is a div, so the heading above cannot name it.
thumbLabel={whitePercentLabel}
/>
</Group>
</Stack>
@@ -1,3 +1,4 @@
import { useId } from "react";
import { Stack, Text, Select, ColorInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ReplaceColorParameters } from "@app/hooks/tools/replaceColor/useReplaceColorParameters";
@@ -18,6 +19,10 @@ const ReplaceColorSettings = ({
disabled = false,
}: ReplaceColorSettingsProps) => {
const { t } = useTranslation();
const operationLabelId = useId();
const highContrastLabelId = useId();
const textColorLabelId = useId();
const backgroundColorLabelId = useId();
const replaceAndInvertOptions = [
{
@@ -60,10 +65,11 @@ const ReplaceColorSettings = ({
return (
<Stack gap="md">
<Stack gap="xs">
<Text size="sm" fw={500}>
<Text id={operationLabelId} size="sm" fw={500}>
{t("replaceColor.labels.colourOperation", "Colour operation")}
</Text>
<Select
aria-labelledby={operationLabelId}
value={parameters.replaceAndInvertOption}
onChange={(value) =>
value &&
@@ -83,10 +89,11 @@ const ReplaceColorSettings = ({
{parameters.replaceAndInvertOption === "HIGH_CONTRAST_COLOR" && (
<Stack gap="xs">
<Text size="sm" fw={500}>
<Text id={highContrastLabelId} size="sm" fw={500}>
{t("replace-color.selectText.5", "High contrast color options")}
</Text>
<Select
aria-labelledby={highContrastLabelId}
value={parameters.highContrastColorCombination}
onChange={(value) =>
value &&
@@ -108,10 +115,11 @@ const ReplaceColorSettings = ({
{parameters.replaceAndInvertOption === "CUSTOM_COLOR" && (
<>
<Stack gap="xs">
<Text size="sm" fw={500}>
<Text id={textColorLabelId} size="sm" fw={500}>
{t("replace-color.selectText.10", "Choose text Color")}
</Text>
<ColorInput
aria-labelledby={textColorLabelId}
value={parameters.textColor}
onChange={(value) => onParameterChange("textColor", value)}
format="hex"
@@ -124,10 +132,11 @@ const ReplaceColorSettings = ({
</Stack>
<Stack gap="xs">
<Text size="sm" fw={500}>
<Text id={backgroundColorLabelId} size="sm" fw={500}>
{t("replace-color.selectText.11", "Choose background Color")}
</Text>
<ColorInput
aria-labelledby={backgroundColorLabelId}
value={parameters.backGroundColor}
onChange={(value) => onParameterChange("backGroundColor", value)}
format="hex"
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useId, useState, useEffect } from "react";
import { Stack, Text, NumberInput } from "@mantine/core";
interface NumberInputWithUnitProps {
@@ -20,6 +20,7 @@ const NumberInputWithUnit = ({
max,
disabled = false,
}: NumberInputWithUnitProps) => {
const labelId = useId();
const [localValue, setLocalValue] = useState<number | string>(value);
// Sync local value when external value changes
@@ -34,6 +35,7 @@ const NumberInputWithUnit = ({
return (
<Stack gap="xs" style={{ flex: 1 }}>
<Text
id={labelId}
size="xs"
fw={500}
style={{
@@ -51,6 +53,7 @@ const NumberInputWithUnit = ({
min={min}
max={max}
disabled={disabled}
aria-labelledby={labelId}
rightSection={
<Text size="sm" c="dimmed" pr="sm">
{unit}
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import OperationButton from "@app/components/tools/shared/OperationButton";
/**
* The run control every tool panel ends with. When it can't run it stays
* visible and explains why rather than disappearing — the reason is what tells
* the user what to fix.
*/
const meta: Meta<typeof OperationButton> = {
title: "Tools/Shared/OperationButton",
component: OperationButton,
parameters: { layout: "padded" },
args: { onClick: () => {}, submitText: "Rotate" },
};
export default meta;
type Story = StoryObj<typeof OperationButton>;
/** Ready to run. */
export const Default: Story = {};
/** Mid-run. */
export const Loading: Story = {
args: { isLoading: true, loadingText: "Rotating…" },
};
/* ── Why it can't run ─────────────────────────────────────────────────────── */
/** No files chosen yet. */
export const NoFiles: Story = {
args: { disabled: true, disabledReason: "noFiles" },
};
/** Files still hydrating. */
export const FilesLoading: Story = {
args: { disabled: true, disabledReason: "filesLoading" },
};
/** Parameters incomplete or invalid. */
export const InvalidParams: Story = {
args: { disabled: true, disabledReason: "invalidParams" },
};
/** The backend endpoint this tool needs is switched off. */
export const EndpointUnavailable: Story = {
args: { disabled: true, disabledReason: "endpointUnavailable" },
};
/** Read-only viewer mode. */
export const ViewerMode: Story = {
args: { disabled: true, disabledReason: "viewerMode" },
};
/** Disabled with no stated reason — the bare fallback. */
export const DisabledNoReason: Story = { args: { disabled: true } };
/* ── Appearance ───────────────────────────────────────────────────────────── */
/** Secondary weight, for a tool whose run isn't the primary action. */
export const Outline: Story = { args: { variant: "outline" } };
/** Lowest weight. */
export const Subtle: Story = { args: { variant: "subtle" } };
/** Destructive tools take the danger accent. */
export const Destructive: Story = {
args: { color: "red", submitText: "Redact permanently" },
};
/** Spanning the panel. */
export const FullWidth: Story = { args: { fullWidth: true } };
/** Marked as running server-side, for tools that can't work purely locally. */
export const WithCloudBadge: Story = { args: { showCloudBadge: true } };

Some files were not shown because too many files have changed in this diff Show More