mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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"
|
||||
|
||||
@@ -2195,6 +2195,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 +3002,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 +3027,7 @@ refresh = "Refresh"
|
||||
remaining = "Remaining"
|
||||
retry = "Retry"
|
||||
save = "Save"
|
||||
stepOf = "Step {{current}} of {{total}}"
|
||||
|
||||
[compare]
|
||||
clearSelected = "Clear selected"
|
||||
@@ -3185,6 +3190,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 +3830,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 +4176,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"
|
||||
@@ -4992,6 +5001,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 +5139,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 +5576,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 +5656,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 +6060,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 +6296,7 @@ revoked = "Revoked"
|
||||
unnamed = "Unnamed instance"
|
||||
|
||||
[portal.accountLink.instances.columns]
|
||||
actions = "Actions"
|
||||
instance = "Instance"
|
||||
lastSeen = "Last seen"
|
||||
linked = "Linked"
|
||||
@@ -6626,6 +6641,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 +7202,7 @@ sensitiveTitle = "Sensitive — access required"
|
||||
|
||||
[portal.documents.table.columns]
|
||||
action = "Pipeline / Action"
|
||||
actions = "Actions"
|
||||
document = "Document"
|
||||
product = "Product"
|
||||
status = "Status"
|
||||
@@ -7483,6 +7500,7 @@ rolledBack = "Rolled back"
|
||||
rolling = "Rolling out"
|
||||
|
||||
[portal.infrastructure.deployments]
|
||||
loadAria = "Load for {{name}}"
|
||||
msValue = "{{value}} ms"
|
||||
throughputValue = "{{value}}/min"
|
||||
|
||||
@@ -7528,6 +7546,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 +7913,7 @@ paused = "Paused"
|
||||
|
||||
[portal.pipelines.table]
|
||||
name = "Pipeline"
|
||||
open = "Open"
|
||||
sources = "Sources"
|
||||
status = "Status"
|
||||
steps = "Steps"
|
||||
@@ -8785,6 +8805,7 @@ unused = "Unused"
|
||||
|
||||
[portal.sources.table]
|
||||
documents = "Documents"
|
||||
open = "Open"
|
||||
source = "Source"
|
||||
status = "Status"
|
||||
usedBy = "Policies"
|
||||
@@ -11325,6 +11346,7 @@ delete = "Delete signature"
|
||||
|
||||
[viewer.thumbnails]
|
||||
closeSidebar = "Close thumbnails sidebar"
|
||||
goToPage = "Go to page {{page}}"
|
||||
|
||||
[viewPdf]
|
||||
tags = "view,read,annotate,text,image,highlight,edit"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+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;
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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(", "),
|
||||
})}
|
||||
|
||||
@@ -216,7 +216,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")}
|
||||
|
||||
@@ -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 } };
|
||||
@@ -44,3 +44,47 @@ export const Empty: Story = {
|
||||
files: [],
|
||||
},
|
||||
};
|
||||
|
||||
/** A 1x1 transparent PNG — enough to fill the thumbnail slot without shipping
|
||||
* a fixture image into the repo. */
|
||||
const PIXEL =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
|
||||
/** Several results with thumbnails — the navigation controls appear and wrap. */
|
||||
export const MultipleFiles: Story = {
|
||||
args: {
|
||||
files: files.map((f) => ({ ...f, thumbnail: PIXEL })),
|
||||
},
|
||||
};
|
||||
|
||||
/** Files present but no thumbnails, so the metadata carries the preview. */
|
||||
export const NoThumbnails: Story = {
|
||||
args: {
|
||||
files: files.slice(0, 2),
|
||||
},
|
||||
};
|
||||
|
||||
/** A custom empty message, for tools whose "no results" means something
|
||||
* specific. */
|
||||
export const CustomEmptyMessage: Story = {
|
||||
args: {
|
||||
files: [],
|
||||
emptyMessage: "No pages matched the redaction pattern.",
|
||||
},
|
||||
};
|
||||
|
||||
/** A long filename, to check it truncates rather than widening the panel. */
|
||||
export const LongFilename: Story = {
|
||||
args: {
|
||||
files: [
|
||||
{
|
||||
file: makeFile(
|
||||
"2026-08-quarterly-consolidated-financial-statements-final-signed.pdf",
|
||||
"application/pdf",
|
||||
245_760,
|
||||
),
|
||||
thumbnail: PIXEL,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -109,9 +109,8 @@ const ToolStep = ({
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
opacity: isCollapsed ? 0.8 : 1,
|
||||
color: isCollapsed ? "var(--mantine-color-dimmed)" : "inherit",
|
||||
transition: "opacity 0.2s ease, color 0.2s ease",
|
||||
transition: "color 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{/* Chevron icon to collapse/expand the step */}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ToolWorkflowTitle } from "@app/components/tools/shared/ToolWorkflowTitle";
|
||||
|
||||
/** The heading every tool panel opens with, optionally with a description line
|
||||
* and an info tooltip. */
|
||||
const meta: Meta<typeof ToolWorkflowTitle> = {
|
||||
title: "Tools/Shared/ToolWorkflowTitle",
|
||||
component: ToolWorkflowTitle,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ToolWorkflowTitle>;
|
||||
|
||||
/** Title alone. */
|
||||
export const Default: Story = { args: { title: "Rotate" } };
|
||||
|
||||
/** With a line explaining what the tool does. */
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
title: "Auto-rotate",
|
||||
description:
|
||||
"Detects each page's orientation and straightens the ones that need it.",
|
||||
},
|
||||
};
|
||||
|
||||
/** An info affordance next to the title, for tools whose behaviour needs
|
||||
* qualifying beyond a single line. */
|
||||
export const WithTooltip: Story = {
|
||||
args: {
|
||||
title: "Redact",
|
||||
description: "Permanently removes the selected content.",
|
||||
tooltip: {
|
||||
header: { title: "About redaction" },
|
||||
content:
|
||||
"Redaction rewrites the page content stream — the removed text cannot be recovered from the output file.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** A long title, to check it wraps rather than pushing the panel wider. */
|
||||
export const LongTitle: Story = {
|
||||
args: {
|
||||
title: "Convert scanned images to a searchable PDF document",
|
||||
description: "Runs OCR over every page and embeds the recognised text.",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Story helper for tool settings panels.
|
||||
*
|
||||
* Most settings panels take a whole parameters *hook* rather than a plain
|
||||
* object, so a story can't just pass args — it needs the hook's shape, with
|
||||
* state that actually updates when the panel writes to it. `useStoryParameters`
|
||||
* builds that from real React state, so a story stays interactive (type in a
|
||||
* field, watch it hold) instead of freezing on a static snapshot.
|
||||
*/
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { BaseParametersHook } from "@app/hooks/tools/shared/useBaseParameters";
|
||||
|
||||
export interface StoryParametersOptions<T> {
|
||||
/** Endpoint the real hook would report; only a few panels surface it. */
|
||||
endpointName?: string;
|
||||
/** Validity gate, mirroring the tool's own validateFn. Defaults to always valid. */
|
||||
validate?: (params: T) => boolean;
|
||||
}
|
||||
|
||||
/** A live BaseParametersHook backed by story state. */
|
||||
export function useStoryParameters<T extends object>(
|
||||
initial: T,
|
||||
{ endpointName = "story", validate }: StoryParametersOptions<T> = {},
|
||||
): BaseParametersHook<T> {
|
||||
const [parameters, setParameters] = useState<T>(initial);
|
||||
|
||||
const updateParameter = useCallback(
|
||||
<K extends keyof T>(parameter: K, value: T[K]) =>
|
||||
setParameters((prev) => ({ ...prev, [parameter]: value })),
|
||||
[],
|
||||
);
|
||||
|
||||
const resetParameters = useCallback(() => setParameters(initial), [initial]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
parameters,
|
||||
setParameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters: () => validate?.(parameters) ?? true,
|
||||
getEndpointName: () => endpointName,
|
||||
}),
|
||||
[parameters, updateParameter, resetParameters, validate, endpointName],
|
||||
);
|
||||
}
|
||||
@@ -129,7 +129,7 @@
|
||||
.showjs-outline-button {
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
color: var(--mantine-color-blue-5);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.showjs-scrollarea {
|
||||
|
||||
@@ -29,7 +29,11 @@ const FavoriteStar: React.FC<FavoriteStarProps> = ({
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
// A span, not a button: this renders inside the tool row's own button in
|
||||
// the fullscreen tool lists, and a control may not nest inside a control.
|
||||
// role="button" is still required for the aria-label to be permitted.
|
||||
as="span"
|
||||
role="button"
|
||||
variant="tertiary"
|
||||
shape="circle"
|
||||
size={SIZE_MAP[size]}
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ const SignatureSection = ({
|
||||
<SignatureStatusBadge signature={signature} />
|
||||
</Group>
|
||||
{signature.errorMessage && (
|
||||
<Text c="red" size="sm">
|
||||
<Text c="var(--color-red-dark)" size="sm">
|
||||
{signature.errorMessage}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
+24
-19
@@ -18,35 +18,40 @@ const SignatureStatusBadge = ({
|
||||
neutral: "status-badge status-badge--neutral",
|
||||
} as const;
|
||||
|
||||
// With no details there is nothing to open, so the badge stays a plain label.
|
||||
// Popover.Target stamps aria-haspopup/aria-expanded onto whatever it wraps,
|
||||
// and those are only permitted on an element that is actually a control.
|
||||
if (status.details.length === 0) {
|
||||
return (
|
||||
<Badge className={classMap[status.kind]} variant="light">
|
||||
{status.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
withinPortal
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
disabled={status.details.length === 0}
|
||||
>
|
||||
<Popover withinPortal position="bottom" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Badge
|
||||
component="button"
|
||||
type="button"
|
||||
className={classMap[status.kind]}
|
||||
variant="light"
|
||||
style={{ cursor: status.details.length ? "pointer" : "default" }}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</Popover.Target>
|
||||
{status.details.length > 0 && (
|
||||
<Popover.Dropdown>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t("details", "Details")}
|
||||
<Popover.Dropdown>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t("details", "Details")}
|
||||
</Text>
|
||||
{status.details.map((d, i) => (
|
||||
<Text size="sm" key={i}>
|
||||
- {d}
|
||||
</Text>
|
||||
{status.details.map((d, i) => (
|
||||
<Text size="sm" key={i}>
|
||||
- {d}
|
||||
</Text>
|
||||
))}
|
||||
</Popover.Dropdown>
|
||||
)}
|
||||
))}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
}
|
||||
|
||||
.attachment-item:hover .attachment-item__download-icon {
|
||||
color: var(--mantine-color-blue-6);
|
||||
color: var(--c-accent-text);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
|
||||
@@ -445,7 +445,7 @@ export const AttachmentSidebar = ({
|
||||
|
||||
{attachmentSupport && documentCacheKey && currentError && (
|
||||
<Stack gap="xs" align="center" className="sidebar-base__error">
|
||||
<Text size="sm" c="red" ta="center">
|
||||
<Text size="sm" c="var(--color-red-dark)" ta="center">
|
||||
{currentError}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user