mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a0556a8d3 | ||
|
|
1659aaba67 | ||
|
|
de9c6f9d41 | ||
|
|
4268c98094 | ||
|
|
37cba3051b | ||
|
|
831213d56b | ||
|
|
550e447b21 | ||
|
|
3aad50ac99 | ||
|
|
3d023cba42 | ||
|
|
d41078a74c | ||
|
|
0631e3d619 | ||
|
|
68d2a96b71 | ||
|
|
3d83f0cddd | ||
|
|
98afa1dbc6 | ||
|
|
d7df847495 | ||
|
|
2c9effffd1 | ||
|
|
6d7e22f8de | ||
|
|
65ba92bbff | ||
|
|
64e96434d8 | ||
|
|
3b2a92f4b3 | ||
|
|
20acfbf0b6 | ||
|
|
cbcfaf3e9d | ||
|
|
34595fa63a | ||
|
|
d0054ae67b | ||
|
|
258ea55657 | ||
|
|
e81c2e819b | ||
|
|
c66b92b397 | ||
|
|
940fa5a25a | ||
|
|
8819c2437c | ||
|
|
93971df7a2 | ||
|
|
4af12f064a | ||
|
|
30c3f92a42 | ||
|
|
9f71d56d5a | ||
|
|
06e421d789 | ||
|
|
5ea0720e13 | ||
|
|
1af43768af | ||
|
|
79c41169d5 | ||
|
|
35586c224f | ||
|
|
707f68f721 | ||
|
|
8fe32659d3 | ||
|
|
2b0f8abc2a |
@@ -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]
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+2
-2
@@ -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 } };
|
||||
+1
-1
@@ -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" }}>
|
||||
|
||||
+1
-1
@@ -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 && (
|
||||
|
||||
+1
-1
@@ -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 = ({
|
||||
|
||||
+25
-11
@@ -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>
|
||||
)}
|
||||
|
||||
+67
@@ -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 /> };
|
||||
+1
-1
@@ -126,6 +126,6 @@
|
||||
/* Preview disclaimer */
|
||||
.previewDisclaimer {
|
||||
margin-top: 8px;
|
||||
opacity: 0.7;
|
||||
color: var(--c-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
+71
@@ -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}
|
||||
|
||||
+62
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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
Reference in New Issue
Block a user