mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58aa68f20e | ||
|
|
13970fb661 | ||
|
|
30350c35de | ||
|
|
3de563bdc4 | ||
|
|
f064c7de44 | ||
|
|
831213d56b | ||
|
|
550e447b21 | ||
|
|
3aad50ac99 | ||
|
|
3d023cba42 | ||
|
|
d41078a74c | ||
|
|
0631e3d619 | ||
|
|
68d2a96b71 | ||
|
|
3d83f0cddd | ||
|
|
98afa1dbc6 | ||
|
|
d7df847495 | ||
|
|
2c9effffd1 | ||
|
|
6d7e22f8de | ||
|
|
65ba92bbff | ||
|
|
64e96434d8 | ||
|
|
3b2a92f4b3 | ||
|
|
20acfbf0b6 | ||
|
|
cbcfaf3e9d | ||
|
|
34595fa63a | ||
|
|
d0054ae67b | ||
|
|
258ea55657 | ||
|
|
e81c2e819b | ||
|
|
c66b92b397 | ||
|
|
940fa5a25a | ||
|
|
8819c2437c | ||
|
|
93971df7a2 | ||
|
|
4af12f064a | ||
|
|
30c3f92a42 | ||
|
|
9f71d56d5a | ||
|
|
06e421d789 | ||
|
|
5ea0720e13 | ||
|
|
1af43768af | ||
|
|
79c41169d5 | ||
|
|
35586c224f | ||
|
|
707f68f721 | ||
|
|
8fe32659d3 | ||
|
|
2b0f8abc2a |
@@ -210,6 +210,16 @@ tasks:
|
||||
# task frontend:storybook:test -- Button
|
||||
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
|
||||
|
||||
storybook:coverage:
|
||||
desc: "Which rendered surfaces have a story, and what each gap would need"
|
||||
summary: |
|
||||
Reports coverage by import rather than by adjacent file, and classifies
|
||||
every gap by the work a story needs — props, a context slice, MSW
|
||||
handlers or router state. Pass --todo to list every gap, or
|
||||
--area <path> to scope it.
|
||||
cmds:
|
||||
- node editor/scripts/storybook-coverage.mjs {{.CLI_ARGS}}
|
||||
|
||||
storybook:a11y:
|
||||
desc: "a11y regression gate over every story: fail only on NEW axe violations"
|
||||
deps: [prepare, storybook:browser]
|
||||
|
||||
@@ -49,3 +49,4 @@ test-results
|
||||
/scripts/dev-update-test/screenshots/
|
||||
/editor/src-tauri/tauri.conf.dev-update.json
|
||||
.a11y-scan/
|
||||
.a11y-acc/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
// Triage helper: turns the raw a11y scan reports into a per-story, per-rule
|
||||
// table with the offending element/colour detail, so each failure can be
|
||||
// attributed to either the Storybook harness or the component itself.
|
||||
//
|
||||
// node a11y-triage.mjs [--in .a11y-scan] [--json out.json]
|
||||
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (n, d) => {
|
||||
const i = args.indexOf(n);
|
||||
return i >= 0 ? args[i + 1] : d;
|
||||
};
|
||||
const inDir = opt("--in", ".a11y-scan");
|
||||
const jsonOut = opt("--json", "");
|
||||
|
||||
const RULE_URL = /dequeuniversity\.com\/rules\/axe\/[\d.]+\/([a-z0-9-]+)/g;
|
||||
const CONTRAST =
|
||||
/contrast of ([\d.]+) \(foreground color: (#[0-9a-f]+), background color: (#[0-9a-f]+)/g;
|
||||
const ELEMENT = /^\s*(<[^\n]{0,160})/gm;
|
||||
|
||||
const rows = [];
|
||||
|
||||
for (const f of readdirSync(inDir).filter((n) => n.endsWith(".json"))) {
|
||||
const data = JSON.parse(readFileSync(join(inDir, f), "utf8"));
|
||||
const visit = (node, name) => {
|
||||
if (Array.isArray(node)) return node.forEach((n) => visit(n, name));
|
||||
if (!node || typeof node !== "object") return;
|
||||
const self = node.fullName || node.title || name;
|
||||
if (Array.isArray(node.failureMessages) && node.failureMessages.length) {
|
||||
const text = node.failureMessages.join("\n");
|
||||
const rules = new Set([...text.matchAll(RULE_URL)].map((m) => m[1]));
|
||||
const colours = [...text.matchAll(CONTRAST)].map((m) => ({
|
||||
ratio: Number(m[1]),
|
||||
fg: m[2],
|
||||
bg: m[3],
|
||||
}));
|
||||
const elements = [...text.matchAll(ELEMENT)]
|
||||
.map((m) => m[1].trim())
|
||||
.filter((e) => e.startsWith("<"))
|
||||
.slice(0, 4);
|
||||
for (const rule of rules) {
|
||||
rows.push({
|
||||
story: self,
|
||||
rule,
|
||||
colours: rule === "color-contrast" ? colours : [],
|
||||
elements,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [k, v] of Object.entries(node)) {
|
||||
if (k === "failureMessages") continue;
|
||||
visit(v, self);
|
||||
}
|
||||
};
|
||||
visit(data, undefined);
|
||||
}
|
||||
|
||||
const byRule = {};
|
||||
for (const r of rows) (byRule[r.rule] ??= []).push(r);
|
||||
|
||||
console.log(`stories with failures: ${new Set(rows.map((r) => r.story)).size}`);
|
||||
console.log(`story-rule pairs: ${rows.length}\n`);
|
||||
for (const [rule, list] of Object.entries(byRule).sort(
|
||||
(a, b) => b[1].length - a[1].length,
|
||||
)) {
|
||||
console.log(`${String(list.length).padStart(4)} ${rule}`);
|
||||
}
|
||||
|
||||
const pairs = {};
|
||||
for (const r of rows)
|
||||
for (const c of r.colours) {
|
||||
const k = `${c.fg} on ${c.bg} (${c.ratio})`;
|
||||
pairs[k] = (pairs[k] ?? 0) + 1;
|
||||
}
|
||||
if (Object.keys(pairs).length) {
|
||||
console.log("\ncontrast pairs:");
|
||||
for (const [k, n] of Object.entries(pairs).sort((a, b) => b[1] - a[1]))
|
||||
console.log(`${String(n).padStart(4)} ${k}`);
|
||||
}
|
||||
|
||||
if (jsonOut) {
|
||||
writeFileSync(jsonOut, JSON.stringify(rows, null, 2));
|
||||
console.log(`\nwrote ${jsonOut}`);
|
||||
}
|
||||
@@ -18,7 +18,14 @@ import { TierProvider, type Tier } from "@portal/contexts/TierContext";
|
||||
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { UIProvider } from "@portal/contexts/UIContext";
|
||||
import { PreferencesProvider } from "@core/contexts/PreferencesContext";
|
||||
import { SidebarProvider } from "@core/contexts/SidebarContext";
|
||||
import { SuiProvider } from "@portal/theme/SuiProvider";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import {
|
||||
mantineTheme as editorMantineTheme,
|
||||
editorCssVariablesResolver,
|
||||
} from "@core/theme/mantineTheme";
|
||||
import { handlers } from "@portal/mocks/handlers";
|
||||
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
|
||||
import i18next from "i18next";
|
||||
@@ -29,6 +36,11 @@ import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@core/tokens/tokens.css";
|
||||
import "@core/theme/index.css";
|
||||
// The editor's Mantine theme resolves its palette through the --color-* vocab
|
||||
// defined here. The app picks this up via styles/tailwind.css; Storybook has no
|
||||
// tailwind entry, so without it every var(--color-*) in the theme is undefined
|
||||
// and Mantine silently falls back to its stock palette.
|
||||
import "@core/styles/theme.css";
|
||||
import "@core/tokens/base.css";
|
||||
|
||||
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
|
||||
@@ -190,6 +202,37 @@ const withLocale: Decorator = (Story, context) => {
|
||||
return <Story />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the Mantine theme the story's component actually runs under in the
|
||||
* app: PortalApp wraps the Processor in SuiProvider, while the editor wraps
|
||||
* everything else in its own ThemeProvider. Getting this wrong is not just
|
||||
* cosmetic — the two themes carry different neutral ramps, so rendering an
|
||||
* editor component under the Processor's theme drops it onto Mantine's stock
|
||||
* greys and reports contrast failures the app doesn't have.
|
||||
*/
|
||||
function StoryTheme({
|
||||
isPortalStory,
|
||||
colorScheme,
|
||||
children,
|
||||
}: {
|
||||
isPortalStory: boolean;
|
||||
colorScheme: "light" | "dark";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (isPortalStory) {
|
||||
return <SuiProvider colorScheme={colorScheme}>{children}</SuiProvider>;
|
||||
}
|
||||
return (
|
||||
<MantineProvider
|
||||
theme={editorMantineTheme}
|
||||
cssVariablesResolver={editorCssVariablesResolver}
|
||||
forceColorScheme={colorScheme}
|
||||
>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const withProviders: Decorator = (Story, context) => {
|
||||
const tier = (context.globals.tier as Tier) ?? "pro";
|
||||
const linkState =
|
||||
@@ -201,25 +244,36 @@ const withProviders: Decorator = (Story, context) => {
|
||||
// anything that isn't "dark" as light — matching the addon's own
|
||||
// `selected || defaultTheme` fallback where defaultTheme is light.
|
||||
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
|
||||
// Storybook titles are the routing key here: the Processor's stories are all
|
||||
// filed under "Portal/".
|
||||
const isPortalStory = (context.title ?? "").startsWith("Portal/");
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<SchemeSetup scheme={colorScheme} />
|
||||
<ThemeBridge theme={colorScheme}>
|
||||
<SuiProvider colorScheme={colorScheme}>
|
||||
<StoryTheme isPortalStory={isPortalStory} colorScheme={colorScheme}>
|
||||
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
|
||||
from useLink() (matches App.tsx's nesting). */}
|
||||
<LinkProvider key={linkState} initialState={linkState}>
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
{/* Tooltip reads the user's logo preference and the sidebar
|
||||
geometry it positions against. It is used by ~100
|
||||
components, so without these a story that renders one
|
||||
throws. The real app always has both mounted. */}
|
||||
<PreferencesProvider>
|
||||
<SidebarProvider>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</SidebarProvider>
|
||||
</PreferencesProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</SuiProvider>
|
||||
</StoryTheme>
|
||||
</ThemeBridge>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
@@ -246,6 +300,14 @@ const preview: Preview = {
|
||||
// any violation. Context is left at the addon default (the document root)
|
||||
// so it resolves under both the Storybook UI and the Vitest browser mount.
|
||||
test: "error",
|
||||
context: {
|
||||
// Nodes carrying this attribute render a facsimile of the user's own
|
||||
// document — their stamp text, their watermark, in the colour and
|
||||
// opacity they chose. WCAG contrast governs the interface, not the
|
||||
// content authored through it, and the controls that set those values
|
||||
// are checked normally.
|
||||
exclude: ["[data-user-content-preview]"],
|
||||
},
|
||||
},
|
||||
},
|
||||
globalTypes: {
|
||||
|
||||
@@ -2805,12 +2805,15 @@ tooltip = "Runs in the cloud (included, no extra charge)"
|
||||
tooltip = "Pick colour from screen"
|
||||
|
||||
[colorPicker]
|
||||
hue = "Hue"
|
||||
saturation = "Saturation and brightness"
|
||||
title = "Choose colour"
|
||||
|
||||
[common]
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
close = "Close"
|
||||
codeSample = "Code sample"
|
||||
collapse = "Collapse"
|
||||
confirm = "Confirm"
|
||||
continue = "Continue"
|
||||
@@ -2826,6 +2829,7 @@ previous = "Previous"
|
||||
refresh = "Refresh"
|
||||
retry = "Retry"
|
||||
save = "Save"
|
||||
stepOf = "Step {{current}} of {{total}}"
|
||||
|
||||
[compare]
|
||||
clearSelected = "Clear selected"
|
||||
@@ -2988,6 +2992,7 @@ title = "Compression Method"
|
||||
[compress.settings]
|
||||
desiredSize = "Desired File Size"
|
||||
desiredSizePlaceholder = "Enter size"
|
||||
desiredSizeUnit = "Size unit"
|
||||
|
||||
[compress.tooltip.description]
|
||||
text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually."
|
||||
@@ -3644,11 +3649,13 @@ shareSelected = "Share Files"
|
||||
sharing = "Sharing"
|
||||
showAll = "Show All"
|
||||
showHistory = "Show History"
|
||||
sortBy = "Sort files"
|
||||
sortByDate = "Sort by Date"
|
||||
sortByName = "Sort by Name"
|
||||
sortBySize = "Sort by Size"
|
||||
storage = "Storage"
|
||||
storageState = "Storage"
|
||||
storageUsed = "Storage used"
|
||||
synced = "Synced"
|
||||
title = "Upload PDF Files"
|
||||
toolChain = "Tools Applied"
|
||||
@@ -4000,6 +4007,7 @@ noFilesInStorageOpen = "No files available in storage. Open some files first."
|
||||
open = "Open"
|
||||
openFile = "Open File"
|
||||
openFiles = "Open Files"
|
||||
selectFile = "Select {{name}}"
|
||||
selectFromStorage = "Select from Storage"
|
||||
upload = "Upload"
|
||||
uploadFile = "Upload File"
|
||||
@@ -4829,6 +4837,7 @@ filesReceived_other = "{{count}} files received"
|
||||
instructions = "Scan with your phone camera. Images convert to PDF automatically."
|
||||
instructionsNoConvert = "Scan with your phone camera to upload files."
|
||||
pollingError = "Error checking for files"
|
||||
qrCodeTitle = "QR code linking to the mobile upload page"
|
||||
sessionCreateError = "Failed to create session"
|
||||
title = "Upload from Mobile"
|
||||
|
||||
@@ -4966,6 +4975,7 @@ activeFiles = "The <strong>Active Files</strong> view shows all of the PDFs you
|
||||
allTools = "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools."
|
||||
close = "Close"
|
||||
cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."
|
||||
dialogLabel = "Onboarding"
|
||||
fileCheckbox = "Clicking one of the files selects it for processing. You can select multiple files for batch operations."
|
||||
fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."
|
||||
filesButton = "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."
|
||||
@@ -5362,6 +5372,7 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man
|
||||
freeTitle = "Unlimited PDF editing"
|
||||
|
||||
[payg.free.hero]
|
||||
barAria = "Free PDFs used"
|
||||
capSuffix = "/ {{limit}} free PDFs"
|
||||
metaCategories = "Automation · AI · API requests"
|
||||
|
||||
@@ -5419,6 +5430,7 @@ automation = "automations"
|
||||
default = "this feature"
|
||||
|
||||
[payg.spendCapMeter]
|
||||
barAria = "Spend against cap"
|
||||
capSuffix = "/ {{amount}} cap"
|
||||
metaCategories = "Automation · AI · API spend"
|
||||
resets = "Resets each billing period"
|
||||
@@ -5822,6 +5834,7 @@ small = "500 Credits"
|
||||
xsmall = "100 Credits"
|
||||
|
||||
[plan.availablePlans]
|
||||
currency = "Billing currency"
|
||||
subtitle = "Choose the plan that fits your needs"
|
||||
title = "Available Plans"
|
||||
|
||||
@@ -6221,6 +6234,7 @@ revoked = "Revoked"
|
||||
unnamed = "Unnamed instance"
|
||||
|
||||
[portal.accountLink.instances.columns]
|
||||
actions = "Actions"
|
||||
instance = "Instance"
|
||||
lastSeen = "Last seen"
|
||||
linked = "Linked"
|
||||
@@ -6510,6 +6524,7 @@ reachedTitle = "Monthly spend limit reached"
|
||||
title = "Couldn't open Stripe portal"
|
||||
|
||||
[portal.billing.walletMeter]
|
||||
barAria = "Free PDFs used"
|
||||
capSuffix_one = "of {{allowance}} free PDFs used"
|
||||
capSuffix_other = "of {{allowance}} free PDFs used"
|
||||
eyebrow = "Processor trial"
|
||||
@@ -6728,6 +6743,7 @@ sensitiveTitle = "Sensitive — access required"
|
||||
|
||||
[portal.documents.table.columns]
|
||||
action = "Pipeline / Action"
|
||||
actions = "Actions"
|
||||
document = "Document"
|
||||
product = "Product"
|
||||
status = "Status"
|
||||
@@ -6979,6 +6995,7 @@ title = "Create API key"
|
||||
titleCreated = "Key created"
|
||||
|
||||
[portal.infrastructure.deployments]
|
||||
loadAria = "Load for {{name}}"
|
||||
msValue = "{{value}} ms"
|
||||
throughputValue = "{{value}}/min"
|
||||
|
||||
@@ -7015,6 +7032,7 @@ title = "No regions deployed"
|
||||
|
||||
[portal.infrastructure.models]
|
||||
heading = "Models"
|
||||
loadAria = "Load for {{name}}"
|
||||
msValue = "{{value}} ms"
|
||||
subheading = "The model catalogue and routing that powers document processing across your workspace."
|
||||
|
||||
@@ -7322,6 +7340,7 @@ paused = "Paused"
|
||||
|
||||
[portal.pipelines.table]
|
||||
name = "Pipeline"
|
||||
open = "Open"
|
||||
sources = "Sources"
|
||||
status = "Status"
|
||||
steps = "Steps"
|
||||
@@ -7923,6 +7942,7 @@ disabled = "Disabled"
|
||||
unused = "Unused"
|
||||
|
||||
[portal.sources.table]
|
||||
open = "Open"
|
||||
source = "Source"
|
||||
status = "Status"
|
||||
usedBy = "Policies"
|
||||
@@ -10246,6 +10266,7 @@ unknownFile = "Unknown file"
|
||||
view = "View"
|
||||
zoom = "Zoom"
|
||||
zoomIn = "Zoom In"
|
||||
zoomLevel = "Zoom level"
|
||||
zoomOut = "Zoom Out"
|
||||
|
||||
[viewer.attachments]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
// Storybook coverage report — which rendered surfaces have a story, and what
|
||||
// stands between the ones that don't and having one. Modes:
|
||||
//
|
||||
// node storybook-coverage.mjs summary by area (report only)
|
||||
// node storybook-coverage.mjs --todo every uncovered surface, with the
|
||||
// work each needs
|
||||
// node storybook-coverage.mjs --area core/components/tools
|
||||
//
|
||||
// Coverage is counted by *import*, not by an adjacent .stories.tsx: several
|
||||
// components are covered by a shared story file (MantineForms covers Select,
|
||||
// MultiSelect, NumberInput and ColorInput between them), and counting siblings
|
||||
// reports those as gaps and invites duplicate stories.
|
||||
//
|
||||
// Each uncovered surface is classified by what a story would have to supply:
|
||||
//
|
||||
// props no context to supply. Note this means "no provider needed", not
|
||||
// "cheap" — a props-only component can still be expensive to
|
||||
// story if its props are heavy (ButtonAppearanceOverlay wants
|
||||
// real PDF bytes; AppConfigModalLazy lazy-loads the whole
|
||||
// settings tree). Read the props before assuming it is quick.
|
||||
// context it (or something it renders) reads a React context. The cheap
|
||||
// fix is usually to export the context and hand the story the
|
||||
// slice the component actually touches, rather than mounting the
|
||||
// provider and whatever chain sits behind it.
|
||||
// data it fetches, so the story needs MSW handlers
|
||||
// router it reads router state
|
||||
//
|
||||
// Not counted as surfaces at all: providers, contexts, gates, routers, test
|
||||
// helpers, and modules that return a config object rather than markup.
|
||||
//
|
||||
// Known blocker — four flavours cannot be storied as things stand.
|
||||
//
|
||||
// Storybook resolves @app/* through editor/tsconfig.proprietary.vite.json,
|
||||
// which maps it to src/proprietary/* then src/core/* and excludes src/desktop.
|
||||
// A file in another flavour that imports an @app/* asset living only in its own
|
||||
// tree therefore fails to resolve, and the story file does not load at all:
|
||||
//
|
||||
// desktop 80 of 152 @app/ imports unresolvable
|
||||
// saas 54 of 232
|
||||
// cloud 28 of 77
|
||||
// prototypes 4 of 40
|
||||
// portal-saas 0 of 7 (fine)
|
||||
//
|
||||
// That accounts for the 0% areas below — it is a build-config gap, not
|
||||
// neglect. Closing it means either per-flavour alias projects in
|
||||
// .storybook/main.ts or hoisting the shared assets, and it is a decision with
|
||||
// blast radius across every existing story, so it is deliberately not made
|
||||
// here.
|
||||
//
|
||||
// Run from frontend/.
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
const SRC = resolve(process.cwd(), "editor/src");
|
||||
const args = process.argv.slice(2);
|
||||
const wantTodo = args.includes("--todo");
|
||||
const areaFilter = args.includes("--area")
|
||||
? args[args.indexOf("--area") + 1]
|
||||
: null;
|
||||
|
||||
/* ── file walk ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (entry.name.endsWith(".tsx")) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const all = walk(SRC);
|
||||
const rel = (f) => relative(SRC, f).split("\\").join("/");
|
||||
const storyFiles = all.filter((f) => f.endsWith(".stories.tsx"));
|
||||
const sources = all.filter(
|
||||
(f) => !f.endsWith(".stories.tsx") && !f.endsWith(".test.tsx"),
|
||||
);
|
||||
|
||||
/* ── what the stories already reach ───────────────────────────────────────── */
|
||||
|
||||
const importedNames = new Set();
|
||||
const importedPaths = new Set();
|
||||
for (const f of storyFiles) {
|
||||
const src = readFileSync(f, "utf8");
|
||||
for (const m of src.matchAll(
|
||||
/import\s+(?:type\s+)?(?:\{([^}]*)\}|(\w+))\s*(?:,\s*\{([^}]*)\})?\s*from\s+["']([^"']+)/g,
|
||||
)) {
|
||||
for (const group of [m[1], m[3]]) {
|
||||
if (!group) continue;
|
||||
for (const name of group.split(","))
|
||||
importedNames.add(
|
||||
name.trim().split(" as ")[0].replace("type ", "").trim(),
|
||||
);
|
||||
}
|
||||
if (m[2]) importedNames.add(m[2]);
|
||||
importedPaths.add(m[4]);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── classification ───────────────────────────────────────────────────────── */
|
||||
|
||||
// Bridge: the viewer's *APIBridge components register an API into context and
|
||||
// render null — wiring, like the rest of these.
|
||||
const INFRA_NAME =
|
||||
/(Provider|Providers|Context|Gate|Boundary|Mount|Router|Guard|Bridge)\.tsx$/;
|
||||
const INFRA_DIR = /\/(contexts|test|tests|mocks|hooks|types|utils|api|data)\//;
|
||||
const RENDERS = /return\s*\(?\s*<|=>\s*\(?\s*</;
|
||||
// A module whose exported function returns a config object, not markup.
|
||||
const CONFIG_FACTORY = /:\s*(SlideConfig|ToolFlowConfig|\w+Config)\s*\{/;
|
||||
const DATA = /\buse(Query|Mutation|SWR|InfiniteQuery)\b|\bfetch\w*\(/;
|
||||
const ROUTER = /\buse(Navigate|Params|Location|SearchParams)\b/;
|
||||
const CONTEXT = /\buse[A-Z]\w*\(/g;
|
||||
// Hooks that are plainly not context reads.
|
||||
const LOCAL_HOOK =
|
||||
/^use(State|Effect|Memo|Callback|Ref|Id|Reducer|Context|Translation|LayoutEffect|ImperativeHandle|Transition|DeferredValue|SyncExternalStore|Debounced\w*|Media\w*|Disclosure|Form)$/;
|
||||
// Contexts .storybook/preview.tsx already mounts for every story. A component
|
||||
// that reads only these needs no fixture work, so it counts as props-level.
|
||||
const HARNESS_PROVIDED =
|
||||
/^use(Preferences|SidebarContext|Tier|Link|UI|Theme|QueryClient|Navigate|Location|Params|SearchParams)$/;
|
||||
|
||||
const byPath = new Map(sources.map((f) => [rel(f), f]));
|
||||
|
||||
function localImports(src, fromRel) {
|
||||
const out = [];
|
||||
for (const m of src.matchAll(
|
||||
/from\s+["'](@app\/|@core\/|\.\.?\/)([^"']+)/g,
|
||||
)) {
|
||||
const spec = m[1] + m[2];
|
||||
const guess = spec.replace(/^@app\//, "core/").replace(/^@core\//, "core/");
|
||||
for (const cand of [`${guess}.tsx`, `${guess}/index.tsx`]) {
|
||||
if (byPath.has(cand)) out.push(cand);
|
||||
}
|
||||
void fromRel;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Does this file, or anything it renders, read a context? Depth-limited. */
|
||||
function needsContext(relPath, seen = new Set(), depth = 0) {
|
||||
if (depth > 3 || seen.has(relPath)) return false;
|
||||
seen.add(relPath);
|
||||
const file = byPath.get(relPath);
|
||||
if (!file) return false;
|
||||
const src = readFileSync(file, "utf8");
|
||||
for (const m of src.matchAll(CONTEXT)) {
|
||||
const name = m[0].slice(0, -1);
|
||||
if (!LOCAL_HOOK.test(name) && !HARNESS_PROVIDED.test(name)) return true;
|
||||
}
|
||||
return localImports(src, relPath).some((child) =>
|
||||
needsContext(child, seen, depth + 1),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const file of sources) {
|
||||
const r = rel(file);
|
||||
const base = r.split("/").pop();
|
||||
if (!/^[A-Z]/.test(base)) continue;
|
||||
const src = readFileSync(file, "utf8");
|
||||
if (!RENDERS.test(src)) continue;
|
||||
if (INFRA_NAME.test(base) || INFRA_DIR.test("/" + r)) continue;
|
||||
if (CONFIG_FACTORY.test(src)) continue;
|
||||
|
||||
const stem = base.replace(".tsx", "");
|
||||
const tail = r.replace(".tsx", "");
|
||||
const covered =
|
||||
importedNames.has(stem) ||
|
||||
[...importedPaths].some((p) => p.endsWith(tail) || p.endsWith("/" + stem));
|
||||
|
||||
let needs = "props";
|
||||
if (DATA.test(src)) needs = "data";
|
||||
else if (ROUTER.test(src)) needs = "router";
|
||||
else if (needsContext(r)) needs = "context";
|
||||
|
||||
const parts = r.split("/");
|
||||
rows.push({
|
||||
area: parts.slice(0, Math.min(3, parts.length - 1)).join("/"),
|
||||
file: r,
|
||||
covered,
|
||||
needs,
|
||||
loc: src.split("\n").length,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── report ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const shown = areaFilter
|
||||
? rows.filter((r) => r.file.startsWith(areaFilter))
|
||||
: rows;
|
||||
const todo = shown.filter((r) => !r.covered);
|
||||
|
||||
if (wantTodo || areaFilter) {
|
||||
const order = { props: 0, context: 1, router: 2, data: 3 };
|
||||
for (const r of todo.sort(
|
||||
(a, b) => order[a.needs] - order[b.needs] || a.loc - b.loc,
|
||||
)) {
|
||||
console.log(
|
||||
` ${r.needs.padEnd(8)} ${String(r.loc).padStart(5)} loc ${r.file}`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
const areas = new Map();
|
||||
for (const r of shown) {
|
||||
const a = areas.get(r.area) ?? { total: 0, covered: 0 };
|
||||
a.total += 1;
|
||||
if (r.covered) a.covered += 1;
|
||||
areas.set(r.area, a);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${"area".padEnd(38)}${"total".padStart(6)}${"covered".padStart(9)}${"%".padStart(6)}`,
|
||||
);
|
||||
for (const [area, a] of [...areas].sort(
|
||||
(x, y) => y[1].total - y[1].covered - (x[1].total - x[1].covered),
|
||||
)) {
|
||||
if (a.total === a.covered) continue;
|
||||
const pct = Math.round((100 * a.covered) / a.total);
|
||||
console.log(
|
||||
`${area.padEnd(38)}${String(a.total).padStart(6)}${String(a.covered).padStart(9)}${String(pct).padStart(5)}%`,
|
||||
);
|
||||
}
|
||||
|
||||
const covered = shown.filter((r) => r.covered).length;
|
||||
const byNeed = todo.reduce(
|
||||
(acc, r) => ((acc[r.needs] = (acc[r.needs] ?? 0) + 1), acc),
|
||||
{},
|
||||
);
|
||||
console.log(
|
||||
`\nsurfaces ${shown.length} covered ${covered} (${Math.round((100 * covered) / shown.length)}%) remaining ${todo.length}`,
|
||||
);
|
||||
console.log(
|
||||
`remaining by what a story needs: ` +
|
||||
Object.entries(byNeed)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${k} ${v}`)
|
||||
.join(" "),
|
||||
);
|
||||
@@ -95,7 +95,7 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.payg-planhead__lbl--free {
|
||||
color: var(--c-success);
|
||||
color: var(--color-green-dark);
|
||||
}
|
||||
.payg-planhead__lbl--meter {
|
||||
color: var(--payg-accent);
|
||||
@@ -242,7 +242,7 @@
|
||||
background: color-mix(in srgb, var(--c-success) 14%, transparent);
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .payg-hero__credit {
|
||||
color: var(--c-success);
|
||||
color: var(--color-green-dark);
|
||||
background: color-mix(in srgb, var(--c-success) 18%, transparent);
|
||||
}
|
||||
|
||||
@@ -459,11 +459,11 @@
|
||||
}
|
||||
.payg-gate[data-enabled="true"] .payg-gate__chip {
|
||||
background: color-mix(in srgb, var(--c-success) 16%, transparent);
|
||||
color: var(--c-success);
|
||||
color: var(--color-green-dark);
|
||||
}
|
||||
.payg-gate[data-enabled="false"] .payg-gate__chip {
|
||||
background: color-mix(in srgb, var(--c-danger) 16%, transparent);
|
||||
color: var(--c-danger);
|
||||
color: var(--color-red-dark);
|
||||
}
|
||||
.payg-gate__label {
|
||||
font-size: 0.8125rem;
|
||||
@@ -486,11 +486,11 @@
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
.payg-gate__tag[data-variant="pause"] {
|
||||
color: var(--c-danger);
|
||||
color: var(--color-red-dark);
|
||||
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .payg-gate__tag[data-variant="pause"] {
|
||||
color: var(--c-danger);
|
||||
color: var(--color-red-dark);
|
||||
background: color-mix(in srgb, var(--c-danger) 18%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
.paygf-explainer__icon--free {
|
||||
color: var(--c-success);
|
||||
color: var(--color-green-dark);
|
||||
}
|
||||
.paygf-explainer__icon--paid {
|
||||
color: var(--payg-accent);
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
|
||||
.scc {
|
||||
--scc-accent: var(--c-primary);
|
||||
--scc-accent-text: var(--c-primary);
|
||||
--scc-accent-text: var(--c-accent-text);
|
||||
--scc-accent-soft: color-mix(in srgb, var(--c-primary) 12%, transparent);
|
||||
--scc-accent-border: color-mix(in srgb, var(--c-primary) 25%, transparent);
|
||||
--scc-chip-bg: var(--c-surface-sunken);
|
||||
@@ -24,7 +24,7 @@
|
||||
[data-mantine-color-scheme="dark"] .scc {
|
||||
/* Chip surface/border track the neutral --c-* tokens (base rule); only the
|
||||
brand-azure accent is tuned brighter for dark. */
|
||||
--scc-accent-text: var(--c-primary);
|
||||
--scc-accent-text: var(--c-accent-text);
|
||||
--scc-accent-soft: color-mix(in srgb, var(--c-primary) 16%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
barLabel={t("payg.free.hero.barAria", "Free PDFs used")}
|
||||
figure={snap.billableUsed.toLocaleString()}
|
||||
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
|
||||
limit: snap.billableLimit.toLocaleString(),
|
||||
@@ -122,6 +123,7 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
barLabel={t("payg.spendCapMeter.barAria", "Spend against cap")}
|
||||
figure={`${symbol}${snap.spent.toLocaleString()}`}
|
||||
capSuffix={t("payg.spendCapMeter.capSuffix", "/ {{amount}} cap", {
|
||||
amount: `${symbol}${snap.cap.toLocaleString()}`,
|
||||
@@ -193,6 +195,7 @@ export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
|
||||
<MeterBar
|
||||
state={state}
|
||||
pct={pct}
|
||||
barLabel={t("payg.prepaid.card.title", "Prepaid capacity")}
|
||||
figure={snap.remaining.toLocaleString()}
|
||||
capSuffix={t(
|
||||
"payg.prepaid.meter.capSuffix",
|
||||
|
||||
@@ -39,6 +39,7 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
||||
</Text>
|
||||
{storageStats.quota && (
|
||||
<Progress
|
||||
aria-label={t("fileManager.storageUsed", "Storage used")}
|
||||
value={storageUsagePercent}
|
||||
color={
|
||||
storageUsagePercent > 80
|
||||
|
||||
@@ -51,6 +51,13 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={onColorChange}
|
||||
// The saturation area and hue bar are role="slider" divs; these are
|
||||
// their only accessible names.
|
||||
saturationLabel={t(
|
||||
"colorPicker.saturation",
|
||||
"Saturation and brightness",
|
||||
)}
|
||||
hueLabel={t("colorPicker.hue", "Hue")}
|
||||
swatches={[
|
||||
"#000000",
|
||||
"#0066cc",
|
||||
@@ -73,6 +80,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
max={100}
|
||||
value={opacity}
|
||||
onChange={onOpacityChange}
|
||||
thumbLabel={resolvedOpacityLabel}
|
||||
marks={[
|
||||
{ value: 25, label: "25%" },
|
||||
{ value: 50, label: "50%" },
|
||||
|
||||
@@ -161,7 +161,7 @@ const AddFileCard = ({
|
||||
icon={icons.uploadIconName}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--c-primary)", flexShrink: 0 }}
|
||||
style={{ color: "var(--c-accent-text)", flexShrink: 0 }}
|
||||
/>
|
||||
{isUploadHover && (
|
||||
<span
|
||||
|
||||
@@ -107,7 +107,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{currentFile && ` • v${currentFile.versionNumber || 1}`}
|
||||
</Text>
|
||||
{hasMultipleFiles && (
|
||||
<Text size="xs" c="blue">
|
||||
<Text size="xs" c="var(--c-accent-text)">
|
||||
{currentFileIndex + 1} of {selectedFiles.length}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -103,7 +103,7 @@ const EmptyFilesState: React.FC = () => {
|
||||
icon={icons.uploadIconName}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--c-primary)" }}
|
||||
style={{ color: "var(--c-accent-text)" }}
|
||||
/>
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: ".5rem" }}>
|
||||
|
||||
@@ -86,19 +86,29 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
bg="gray.4"
|
||||
p="sm"
|
||||
style={{
|
||||
background: "var(--c-surface-raised)",
|
||||
borderTopLeftRadius: "var(--mantine-radius-md)",
|
||||
borderTopRightRadius: "var(--mantine-radius-md)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={500} ta="center" c="white">
|
||||
<Text size="sm" fw={500} ta="center">
|
||||
{t("fileManager.details", "File Details")}
|
||||
</Text>
|
||||
</Box>
|
||||
<ScrollArea style={{ flex: 1, minHeight: 0 }} p="md">
|
||||
{/* The viewport is focusable and named so keyboard users can scroll the
|
||||
detail list once it overflows. */}
|
||||
<ScrollArea
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
p="md"
|
||||
viewportProps={{
|
||||
tabIndex: 0,
|
||||
role: "group",
|
||||
"aria-label": t("fileManager.details", "File Details"),
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
@@ -208,11 +208,13 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
>
|
||||
<Group gap="sm">
|
||||
{!isHistoryFile && (
|
||||
<Box>
|
||||
{/* Checkbox for regular files only */}
|
||||
<Box onClick={(e) => e.stopPropagation()}>
|
||||
{/* The row's own onClick is mouse-only, so the checkbox has to
|
||||
carry the keyboard path rather than deferring to it. Its
|
||||
click stops above so a mouse press doesn't toggle twice. */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // Handled by parent onClick
|
||||
onChange={() => onSelect(false)}
|
||||
size="sm"
|
||||
pl="sm"
|
||||
pr="xs"
|
||||
|
||||
@@ -906,35 +906,47 @@ function ListView({
|
||||
|
||||
return (
|
||||
<div className="files-page-list" role="grid">
|
||||
{/* Each direct child is a columnheader: a role="row" may only own cells, so
|
||||
the sort controls and the select-all box have to sit inside one. */}
|
||||
<div className="files-page-list-row is-header" role="row">
|
||||
{onSetSelection && visibleFileIds.length > 0 ? (
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={() => {
|
||||
onSetSelection(allSelected ? new Set() : new Set(visibleFileIds));
|
||||
}}
|
||||
aria-label={
|
||||
allSelected
|
||||
? t("filesPage.deselectAll", "Clear selection")
|
||||
: t("filesPage.selectAll", "Select all")
|
||||
}
|
||||
/>
|
||||
<span role="columnheader">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={() => {
|
||||
onSetSelection(
|
||||
allSelected ? new Set() : new Set(visibleFileIds),
|
||||
);
|
||||
}}
|
||||
aria-label={
|
||||
allSelected
|
||||
? t("filesPage.deselectAll", "Clear selection")
|
||||
: t("filesPage.selectAll", "Select all")
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
<span {...headerProps("name-asc", "name-desc")}>
|
||||
{t("filesPage.column.name", "Name")}
|
||||
{sortIndicator("name-asc", "name-desc")}
|
||||
<span role="columnheader">
|
||||
<span {...headerProps("name-asc", "name-desc")}>
|
||||
{t("filesPage.column.name", "Name")}
|
||||
{sortIndicator("name-asc", "name-desc")}
|
||||
</span>
|
||||
</span>
|
||||
<span>{t("filesPage.column.type", "Type")}</span>
|
||||
<span {...headerProps("size-asc", "size-desc")}>
|
||||
{t("filesPage.column.size", "Size")}
|
||||
{sortIndicator("size-asc", "size-desc")}
|
||||
<span role="columnheader">{t("filesPage.column.type", "Type")}</span>
|
||||
<span role="columnheader">
|
||||
<span {...headerProps("size-asc", "size-desc")}>
|
||||
{t("filesPage.column.size", "Size")}
|
||||
{sortIndicator("size-asc", "size-desc")}
|
||||
</span>
|
||||
</span>
|
||||
<span {...headerProps("modified-asc", "modified-desc")}>
|
||||
{t("filesPage.column.modified", "Modified")}
|
||||
{sortIndicator("modified-asc", "modified-desc")}
|
||||
<span role="columnheader">
|
||||
<span {...headerProps("modified-asc", "modified-desc")}>
|
||||
{t("filesPage.column.modified", "Modified")}
|
||||
{sortIndicator("modified-asc", "modified-desc")}
|
||||
</span>
|
||||
</span>
|
||||
<span aria-hidden="true" />
|
||||
</div>
|
||||
@@ -1091,7 +1103,12 @@ function FolderRow({
|
||||
className={`files-page-list-row${isDropTarget ? " is-drop-target" : ""}`}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{/* Each direct child is a gridcell: a role="row" may only own cells, so the
|
||||
actions menu has to sit inside one. */}
|
||||
<span
|
||||
role="gridcell"
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<FolderThumbnail
|
||||
color={folder.color}
|
||||
size="row"
|
||||
@@ -1119,61 +1136,65 @@ function FolderRow({
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span>{t("filesPage.folder", "Folder")}</span>
|
||||
<span>
|
||||
<span role="gridcell">{t("filesPage.folder", "Folder")}</span>
|
||||
<span role="gridcell">
|
||||
{fileCount === 0
|
||||
? "-"
|
||||
: t("filesPage.folderItems", "{{count}} items", { count: fileCount })}
|
||||
</span>
|
||||
<span>{getFileDate({ lastModified: folder.updatedAt })}</span>
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.folderMenu", "Folder actions")}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{t("filesPage.open", "Open")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
|
||||
onClick={onRename}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.rename", "Rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>
|
||||
{t("filesPage.appearance.title", "Appearance")}
|
||||
</Menu.Label>
|
||||
<FolderAppearancePicker
|
||||
folder={folder}
|
||||
onChange={onChangeAppearance}
|
||||
disabled={!serverReachable}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={onDelete}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.deleteFolder", "Delete folder")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<span role="gridcell">
|
||||
{getFileDate({ lastModified: folder.updatedAt })}
|
||||
</span>
|
||||
<span role="gridcell">
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.folderMenu", "Folder actions")}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{t("filesPage.open", "Open")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
|
||||
onClick={onRename}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.rename", "Rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>
|
||||
{t("filesPage.appearance.title", "Appearance")}
|
||||
</Menu.Label>
|
||||
<FolderAppearancePicker
|
||||
folder={folder}
|
||||
onChange={onChangeAppearance}
|
||||
disabled={!serverReachable}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={onDelete}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.deleteFolder", "Delete folder")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1254,35 +1275,40 @@ function FileRow({
|
||||
isInWorkspace ? " is-in-workspace" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox only shows in multi-select mode (see FileCard). When the
|
||||
checkbox is hidden the first grid column collapses, but the row's
|
||||
CSS grid keeps the columns aligned via the named template, so no
|
||||
empty cell shows. */}
|
||||
{/* Each direct child is a gridcell: a role="row" may only own cells, so the
|
||||
checkbox and the actions menu have to sit inside one.
|
||||
|
||||
The checkbox only shows in multi-select mode (see FileCard). When it is
|
||||
hidden the first grid column collapses, but the row's CSS grid keeps the
|
||||
columns aligned via the named template, so no empty cell shows. */}
|
||||
{multiSelectActive ? (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onClick={(e) => {
|
||||
// Toggle this file in/out of the selection without modifier keys.
|
||||
e.stopPropagation();
|
||||
onClick({
|
||||
...e,
|
||||
shiftKey: false,
|
||||
ctrlKey: true,
|
||||
metaKey: true,
|
||||
} as unknown as React.MouseEvent);
|
||||
}}
|
||||
onChange={() => {
|
||||
/* handled by onClick */
|
||||
}}
|
||||
aria-label={t("filesPage.selectFile", "Select file {{name}}", {
|
||||
name: file.name,
|
||||
})}
|
||||
/>
|
||||
<span role="gridcell">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onClick={(e) => {
|
||||
// Toggle this file in/out of the selection without modifier keys.
|
||||
e.stopPropagation();
|
||||
onClick({
|
||||
...e,
|
||||
shiftKey: false,
|
||||
ctrlKey: true,
|
||||
metaKey: true,
|
||||
} as unknown as React.MouseEvent);
|
||||
}}
|
||||
onChange={() => {
|
||||
/* handled by onClick */
|
||||
}}
|
||||
aria-label={t("filesPage.selectFile", "Select file {{name}}", {
|
||||
name: file.name,
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
// Empty cell preserves grid column alignment.
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
<span
|
||||
role="gridcell"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -1342,94 +1368,96 @@ function FileRow({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{ext || t("filesPage.file", "File")}</span>
|
||||
<span>{fileSize}</span>
|
||||
<span>{fileDate}</span>
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.fileMenu", "File actions")}
|
||||
data-testid="file-card-actions"
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.addToWorkspace", "Add to workspace")}
|
||||
</Menu.Item>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
{/* Per-file Save to server; shown for local-only files. When
|
||||
storage is off it stays visible but disabled with a tooltip. */}
|
||||
{onSaveToServer && file.remoteStorageId == null && (
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
position="left"
|
||||
multiline
|
||||
w={240}
|
||||
<span role="gridcell">{ext || t("filesPage.file", "File")}</span>
|
||||
<span role="gridcell">{fileSize}</span>
|
||||
<span role="gridcell">{fileDate}</span>
|
||||
<span role="gridcell">
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.fileMenu", "File actions")}
|
||||
data-testid="file-card-actions"
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
style={
|
||||
saveToServerDisabledReason
|
||||
? { pointerEvents: "auto" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onVersionHistory && (file.versionNumber ?? 1) > 1 && (
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<HistoryIcon fontSize="small" />}
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVersionHistory();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.versionHistory", "Version history")}
|
||||
{t("filesPage.addToWorkspace", "Add to workspace")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.remove", "Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
{/* Per-file Save to server; shown for local-only files. When
|
||||
storage is off it stays visible but disabled with a tooltip. */}
|
||||
{onSaveToServer && file.remoteStorageId == null && (
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
position="left"
|
||||
multiline
|
||||
w={240}
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
style={
|
||||
saveToServerDisabledReason
|
||||
? { pointerEvents: "auto" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onVersionHistory && (file.versionNumber ?? 1) > 1 && (
|
||||
<Menu.Item
|
||||
leftSection={<HistoryIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVersionHistory();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.versionHistory", "Version history")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.remove", "Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ const styles = {
|
||||
},
|
||||
cloud: {
|
||||
background: "color-mix(in srgb, var(--c-primary) 16%, transparent)",
|
||||
color: "var(--c-primary)",
|
||||
color: "var(--c-accent-text)",
|
||||
},
|
||||
shared: {
|
||||
background:
|
||||
"color-mix(in srgb, var(--mantine-color-orange-6) 16%, transparent)",
|
||||
color: "var(--mantine-color-orange-6)",
|
||||
color: "var(--color-amber-dark)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -339,6 +339,9 @@
|
||||
}
|
||||
|
||||
.files-page-list-row.is-header [data-sortable="true"] {
|
||||
/* Block so the hit area and hover tint fill the columnheader cell that wraps
|
||||
it, rather than hugging the label text. */
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: -0.2rem -0.4rem;
|
||||
@@ -741,7 +744,7 @@
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--c-primary) 12%, transparent);
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -980,7 +983,7 @@
|
||||
.files-page-details-version-timeline-count {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@@ -1112,7 +1115,29 @@
|
||||
.files-page-details-version-timeline-expand-btn:hover span {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.files-page-details-version-timeline-delta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--c-text);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.files-page-details-version-timeline-delta.is-origin {
|
||||
font-weight: 400;
|
||||
color: var(--c-text-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
.files-page-details-version-timeline-delta-plus {
|
||||
color: var(--c-accent-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.files-page-details-version-timeline-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.files-page-details-version-timeline-chevron {
|
||||
color: var(--c-text-subtle);
|
||||
transition: transform 0.15s ease;
|
||||
@@ -1120,7 +1145,7 @@
|
||||
|
||||
.files-page-details-version-timeline-chevron.is-expanded {
|
||||
transform: rotate(180deg);
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.files-page-details-version-timeline-expanded {
|
||||
@@ -1185,7 +1210,7 @@
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
pointer-events: none;
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
z-index: 10;
|
||||
|
||||
@@ -151,7 +151,10 @@ export function FolderThumbnail({
|
||||
borderRadius: "999px",
|
||||
background: "var(--c-surface, #fff)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: accent,
|
||||
// The ring carries the folder's accent; the numeral does not.
|
||||
// Folder colours are user-chosen and many are too light to read
|
||||
// as text on the white pill.
|
||||
color: "var(--c-text)",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
display: "inline-flex",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
HotkeyContext,
|
||||
type HotkeyContextValue,
|
||||
} from "@app/contexts/HotkeyContext";
|
||||
import { HotkeyDisplay } from "@app/components/hotkeys/HotkeyDisplay";
|
||||
import { getDisplayParts } from "@app/utils/hotkeys";
|
||||
|
||||
/** A keyboard shortcut rendered as key caps.
|
||||
*
|
||||
* HotkeyProvider pulls in the whole tool-workflow chain, but the display only
|
||||
* needs one function off the context — so the stories supply that slice, using
|
||||
* the real formatter so the caps render exactly as they do in the app. Pinned
|
||||
* to the non-mac glyphs to keep the stories stable across machines. */
|
||||
const withHotkeys = (Story: React.ComponentType) => (
|
||||
<HotkeyContext.Provider
|
||||
value={
|
||||
{
|
||||
getDisplayParts: (binding) => getDisplayParts(binding, false),
|
||||
} as HotkeyContextValue
|
||||
}
|
||||
>
|
||||
<Story />
|
||||
</HotkeyContext.Provider>
|
||||
);
|
||||
|
||||
const meta: Meta<typeof HotkeyDisplay> = {
|
||||
title: "Hotkeys/HotkeyDisplay",
|
||||
component: HotkeyDisplay,
|
||||
parameters: { layout: "centered" },
|
||||
decorators: [withHotkeys],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof HotkeyDisplay>;
|
||||
|
||||
/** A single key. */
|
||||
export const SingleKey: Story = { args: { binding: { code: "KeyS" } } };
|
||||
|
||||
/** The common save shortcut. */
|
||||
export const WithModifier: Story = {
|
||||
args: { binding: { code: "KeyS", ctrl: true } },
|
||||
};
|
||||
|
||||
/** Several modifiers at once. */
|
||||
export const MultipleModifiers: Story = {
|
||||
args: { binding: { code: "KeyP", ctrl: true, shift: true, alt: true } },
|
||||
};
|
||||
|
||||
/** The macOS command modifier. */
|
||||
export const MetaModifier: Story = {
|
||||
args: { binding: { code: "KeyK", meta: true } },
|
||||
};
|
||||
|
||||
/** A non-letter key, which renders its own glyph rather than a letter. */
|
||||
export const ArrowKey: Story = { args: { binding: { code: "ArrowRight" } } };
|
||||
|
||||
/** Both sizes side by side. */
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<HotkeyDisplay binding={{ code: "KeyS", ctrl: true }} size="sm" />
|
||||
<HotkeyDisplay binding={{ code: "KeyS", ctrl: true }} size="md" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Muted, for a shortcut shown beside a disabled action. */
|
||||
export const Muted: Story = {
|
||||
args: { binding: { code: "KeyS", ctrl: true }, muted: true },
|
||||
};
|
||||
|
||||
/** No binding assigned — the component renders nothing rather than an empty
|
||||
* cap, so an unbound action shows no stray chrome. */
|
||||
export const Unbound: Story = { args: { binding: null } };
|
||||
+1
-1
@@ -325,7 +325,7 @@
|
||||
|
||||
.v2Badge {
|
||||
background: var(--c-primary-tint);
|
||||
color: var(--c-accent-fg);
|
||||
color: var(--c-accent-text);
|
||||
padding: 3px 9px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -77,3 +77,72 @@ export const NotDismissible: Story = {
|
||||
allowDismiss: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** The final step — the bar is fully filled and the primary action closes the
|
||||
* tour rather than advancing it. */
|
||||
export const LastStep: Story = {
|
||||
args: {
|
||||
hero: <ShellHero appIcon />,
|
||||
slideKey: "done",
|
||||
title: "You're all set",
|
||||
body: "You can reopen this tour any time from the help menu.",
|
||||
stepIndex: 4,
|
||||
stepCount: 5,
|
||||
buttons: [
|
||||
{ key: "back", back: true, action: "back" },
|
||||
{ key: "finish", label: "Finish", primary: true, action: "finish" },
|
||||
],
|
||||
onAction: () => {},
|
||||
onClose: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
/** An action that is not yet available — shown rather than hidden, so the path
|
||||
* forward stays visible. */
|
||||
export const DisabledAction: Story = {
|
||||
args: {
|
||||
hero: <ShellHero>1</ShellHero>,
|
||||
slideKey: "choose",
|
||||
title: "Choose your install",
|
||||
body: "Pick a platform to continue.",
|
||||
stepIndex: 1,
|
||||
stepCount: 4,
|
||||
buttons: [
|
||||
{ key: "back", back: true, action: "back" },
|
||||
{
|
||||
key: "next",
|
||||
label: "Download",
|
||||
primary: true,
|
||||
action: "next",
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
onAction: () => {},
|
||||
onClose: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
/** A body longer than the viewport must scroll inside the card rather than
|
||||
* pushing the actions off-screen. */
|
||||
export const LongBody: Story = {
|
||||
args: {
|
||||
hero: <ShellHero appIcon />,
|
||||
slideKey: "release-notes",
|
||||
title: "What changed in this release",
|
||||
body: (
|
||||
<div style={{ display: "grid", gap: "0.75rem", textAlign: "left" }}>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<p key={i} style={{ margin: 0 }}>
|
||||
{i + 1}. Batch processing now runs pipelined rather than serialised,
|
||||
so a queue finishes in roughly the time the slowest document takes.
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
stepIndex: 0,
|
||||
stepCount: 1,
|
||||
buttons: [{ key: "ok", label: "Got it", primary: true, action: "close" }],
|
||||
onAction: () => {},
|
||||
onClose: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -101,7 +101,10 @@ export default function OnboardingSlideShell({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
// Composed rather than the plain <Modal>, because only Modal.Content lands
|
||||
// props on the role="dialog" element — the slide draws its own title, so the
|
||||
// dialog needs an aria-label to have an accessible name.
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
closeOnClickOutside={false}
|
||||
@@ -109,7 +112,6 @@ export default function OnboardingSlideShell({
|
||||
centered
|
||||
size="lg"
|
||||
radius={20}
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
|
||||
@@ -121,106 +123,122 @@ export default function OnboardingSlideShell({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className={styles.card}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.brand}>
|
||||
<img
|
||||
src={stirlingMark}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={styles.brandLogo}
|
||||
/>
|
||||
<span className={styles.wordmark}>Stirling</span>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
{showProgress && (
|
||||
<span className={styles.stepPill}>
|
||||
{t("onboarding.stepOf", "Step {{current}} of {{total}}", {
|
||||
current: stepIndex + 1,
|
||||
total: stepCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{allowDismiss && (
|
||||
<ActionIcon
|
||||
onClick={onClose}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
aria-label={t("common.close", "Close")}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
<Modal.Overlay />
|
||||
<Modal.Content
|
||||
radius={20}
|
||||
aria-label={t("onboarding.dialogLabel", "Onboarding")}
|
||||
>
|
||||
<Modal.Body>
|
||||
<div className={styles.card}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.brand}>
|
||||
<img
|
||||
src={stirlingMark}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={styles.brandLogo}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<span className={styles.wordmark}>Stirling</span>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
{showProgress && (
|
||||
<span className={styles.stepPill}>
|
||||
{t("onboarding.stepOf", "Step {{current}} of {{total}}", {
|
||||
current: stepIndex + 1,
|
||||
total: stepCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{allowDismiss && (
|
||||
<ActionIcon
|
||||
onClick={onClose}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
aria-label={t("common.close", "Close")}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showProgress && (
|
||||
<div
|
||||
className={styles.progressTrack}
|
||||
role="progressbar"
|
||||
aria-valuenow={stepIndex + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={stepCount}
|
||||
>
|
||||
{Array.from({ length: stepCount }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`${styles.progressSeg} ${
|
||||
index <= stepIndex ? styles.progressSegDone : ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.heroPanel}>
|
||||
<div className={styles.heroArt} key={`hero-${slideKey}`}>
|
||||
{hero}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key={`title-${slideKey}`} className={styles.titleNew}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div key={`body-${slideKey}`} className={styles.bodyNew}>
|
||||
{body}
|
||||
<style>{`.${styles.bodyNew} strong{color: var(--c-text); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{backButtons.length === 0 ? (
|
||||
<div className={styles.footerEnd}>{actions}</div>
|
||||
) : (
|
||||
<div className={styles.footerBetween}>
|
||||
<div className={styles.footerGroup}>
|
||||
{backButtons.map((button) => (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
disabled={button.disabled}
|
||||
aria-label={t("onboarding.buttons.back", "Back")}
|
||||
>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
))}
|
||||
</div>
|
||||
{actions}
|
||||
{showProgress && (
|
||||
<div
|
||||
className={styles.progressTrack}
|
||||
role="progressbar"
|
||||
aria-valuenow={stepIndex + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={stepCount}
|
||||
aria-label={t(
|
||||
"onboarding.stepOf",
|
||||
"Step {{current}} of {{total}}",
|
||||
{
|
||||
current: stepIndex + 1,
|
||||
total: stepCount,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: stepCount }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`${styles.progressSeg} ${
|
||||
index <= stepIndex ? styles.progressSegDone : ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.heroPanel}>
|
||||
<div className={styles.heroArt} key={`hero-${slideKey}`}>
|
||||
{hero}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key={`title-${slideKey}`} className={styles.titleNew}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div key={`body-${slideKey}`} className={styles.bodyNew}>
|
||||
{body}
|
||||
<style>{`.${styles.bodyNew} strong{color: var(--c-text); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{backButtons.length === 0 ? (
|
||||
<div className={styles.footerEnd}>{actions}</div>
|
||||
) : (
|
||||
<div className={styles.footerBetween}>
|
||||
<div className={styles.footerGroup}>
|
||||
{backButtons.map((button) => (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
disabled={button.disabled}
|
||||
aria-label={t("onboarding.buttons.back", "Back")}
|
||||
>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
))}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ function FirstLoginForm({
|
||||
icon="info-rounded"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ color: "var(--c-primary)", flexShrink: 0 }}
|
||||
style={{ color: "var(--c-accent-text)", flexShrink: 0 }}
|
||||
/>
|
||||
<span>
|
||||
{t(
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function SecurityCheckSlide({
|
||||
icon="error"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ color: "var(--c-danger)", flexShrink: 0 }}
|
||||
style={{ color: "var(--color-red-dark)", flexShrink: 0 }}
|
||||
/>
|
||||
<span>
|
||||
{i18n.t(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel";
|
||||
|
||||
/** A document of `n` pages in the shape the panel reads. */
|
||||
const doc = (n: number) => ({
|
||||
pages: Array.from({ length: n }, (_, i) => ({
|
||||
id: `page-${i + 1}`,
|
||||
pageNumber: i + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* Selecting pages by typing a range rather than clicking thumbnails. The CSV
|
||||
* field is the whole point of the panel, so the stories drive it with real
|
||||
* state — typing into a static snapshot would prove nothing.
|
||||
*/
|
||||
const meta: Meta<typeof BulkSelectionPanel> = {
|
||||
title: "PageEditor/BulkSelectionPanel",
|
||||
component: BulkSelectionPanel,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof BulkSelectionPanel>;
|
||||
|
||||
function Demo({
|
||||
initialCsv = "",
|
||||
selected = [],
|
||||
pages = 24,
|
||||
}: {
|
||||
initialCsv?: string;
|
||||
selected?: string[];
|
||||
pages?: number;
|
||||
}) {
|
||||
const [csvInput, setCsvInput] = useState(initialCsv);
|
||||
return (
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selected}
|
||||
displayDocument={doc(pages)}
|
||||
onUpdatePagesFromCSV={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nothing selected yet. */
|
||||
export const Empty: Story = { render: () => <Demo /> };
|
||||
|
||||
/** A typed range, with the matching pages selected. */
|
||||
export const WithRange: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
initialCsv="1-5"
|
||||
selected={["page-1", "page-2", "page-3", "page-4", "page-5"]}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** A mixed expression — individual pages and ranges together. */
|
||||
export const MixedExpression: Story = {
|
||||
render: () => (
|
||||
<Demo initialCsv="1,4-6,12" selected={["page-1", "page-4", "page-12"]} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Every page selected. */
|
||||
export const AllSelected: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
pages={8}
|
||||
initialCsv="1-8"
|
||||
selected={Array.from({ length: 8 }, (_, i) => `page-${i + 1}`)}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** A single-page document, where ranges have little to do. */
|
||||
export const SinglePage: Story = {
|
||||
render: () => <Demo pages={1} />,
|
||||
};
|
||||
|
||||
/** A long document, to check the summary stays readable as counts grow. */
|
||||
export const LongDocument: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
pages={480}
|
||||
initialCsv="1-200"
|
||||
selected={Array.from({ length: 200 }, (_, i) => `page-${i + 1}`)}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
|
||||
|
||||
const doc = (n: number) => ({
|
||||
pages: Array.from({ length: n }, (_, i) => ({
|
||||
id: `page-${i + 1}`,
|
||||
pageNumber: i + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* The toolbar affordance that opens bulk page selection. It disables itself
|
||||
* when there are no pages to select, so an empty document offers no dead
|
||||
* control.
|
||||
*/
|
||||
const meta: Meta<typeof PageSelectByNumberButton> = {
|
||||
title: "PageEditor/PageSelectByNumberButton",
|
||||
component: PageSelectByNumberButton,
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof PageSelectByNumberButton>;
|
||||
|
||||
function Demo({
|
||||
totalPages = 24,
|
||||
disabled = false,
|
||||
initialCsv = "",
|
||||
selected = [],
|
||||
}: {
|
||||
totalPages?: number;
|
||||
disabled?: boolean;
|
||||
initialCsv?: string;
|
||||
selected?: string[];
|
||||
}) {
|
||||
const [csvInput, setCsvInput] = useState(initialCsv);
|
||||
return (
|
||||
<PageSelectByNumberButton
|
||||
disabled={disabled}
|
||||
totalPages={totalPages}
|
||||
label="Select pages by number"
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selected}
|
||||
displayDocument={doc(totalPages)}
|
||||
updatePagesFromCSV={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Available — click to open the selection popover. */
|
||||
export const Default: Story = { render: () => <Demo /> };
|
||||
|
||||
/** A selection already in place. */
|
||||
export const WithSelection: Story = {
|
||||
render: () => (
|
||||
<Demo initialCsv="2,5-9" selected={["page-2", "page-5", "page-9"]} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Explicitly disabled, e.g. while the document is still loading. */
|
||||
export const Disabled: Story = { render: () => <Demo disabled /> };
|
||||
|
||||
/** No pages: the control disables itself regardless of the `disabled` prop. */
|
||||
export const NoPages: Story = { render: () => <Demo totalPages={0} /> };
|
||||
|
||||
/** A single page — selectable, but ranges have little to do. */
|
||||
export const SinglePage: Story = { render: () => <Demo totalPages={1} /> };
|
||||
@@ -35,16 +35,17 @@ export default function PageSelectByNumberButton({
|
||||
>
|
||||
<div>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
{/* The button is the target, not a wrapper: Popover.Target puts
|
||||
aria-haspopup and aria-expanded on whatever it wraps, and
|
||||
aria-expanded is not a permitted attribute on a plain div. */}
|
||||
<Popover.Target>
|
||||
<div style={{ display: "inline-flex" }}>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
disabled={disabled || totalPages === 0}
|
||||
aria-label={label}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
disabled={disabled || totalPages === 0}
|
||||
aria-label={label}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: "24rem", maxWidth: "32rem" }}>
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ const PageSelectionInput = ({
|
||||
size="sm"
|
||||
checked={!!advancedOpened}
|
||||
onChange={(e) => onToggleAdvanced?.(e.currentTarget.checked)}
|
||||
title={t("bulkSelection.advanced.title", "Advanced")}
|
||||
aria-label={t("bulkSelection.advanced.title", "Advanced")}
|
||||
className={classes.advancedSwitch}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
||||
|
||||
/**
|
||||
* The sidebar brand header. Core has no admin portal to switch to, so this is
|
||||
* just the logo — builds that bundle the portal shadow this file with a version
|
||||
* whose logo doubles as the editor⇄processor switcher. Both states matter here
|
||||
* because the rail collapses.
|
||||
*/
|
||||
const meta: Meta<typeof AppSwitcher> = {
|
||||
title: "Shared/AppSwitcher",
|
||||
component: AppSwitcher,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AppSwitcher>;
|
||||
|
||||
/** Expanded rail — mark and wordmark. */
|
||||
export const Expanded: Story = {};
|
||||
|
||||
/** Collapsed rail — icon only. */
|
||||
export const Collapsed: Story = { args: { collapsed: true } };
|
||||
|
||||
/** Both, to compare the mark's optical size between the two rail widths. */
|
||||
export const BothStates: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "3rem", alignItems: "center" }}>
|
||||
<AppSwitcher />
|
||||
<AppSwitcher collapsed />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import Badge from "@app/components/shared/Badge";
|
||||
|
||||
/** Small inline label. `colored` takes an explicit palette so callers can tint
|
||||
* a badge to whatever the surrounding feature already uses. */
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: "Shared/Badge",
|
||||
component: Badge,
|
||||
parameters: { layout: "centered" },
|
||||
args: { children: "Beta" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
/** Default tone. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The three sizes together, so their baselines can be compared. */
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||
<Badge size="sm">Small</Badge>
|
||||
<Badge size="md">Medium</Badge>
|
||||
<Badge size="lg">Large</Badge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Explicitly tinted. The pairing is the caller's to get right — these use the
|
||||
* semantic tokens rather than raw hues so they hold up in both themes. */
|
||||
export const Colored: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||
<Badge
|
||||
variant="colored"
|
||||
backgroundColor="var(--c-success-solid)"
|
||||
textColor="var(--c-text-on-primary)"
|
||||
>
|
||||
Active
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="colored"
|
||||
backgroundColor="var(--c-warning-solid)"
|
||||
textColor="var(--c-text-on-primary)"
|
||||
>
|
||||
Pending
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="colored"
|
||||
backgroundColor="var(--c-danger-solid)"
|
||||
textColor="var(--c-text-on-primary)"
|
||||
>
|
||||
Failed
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** A long label, to check it stays on one line rather than breaking the row. */
|
||||
export const LongLabel: Story = {
|
||||
args: { children: "Requires the AI engine" },
|
||||
};
|
||||
|
||||
/** A numeral, the other common use. */
|
||||
export const Count: Story = { args: { children: "12", size: "sm" } };
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* The brand marks and the chrome controls that sit beside them. Grouped in one
|
||||
* file because each is a handful of props and they are always seen together in
|
||||
* the app's top-left corner.
|
||||
*/
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button, Dropdown } from "@app/ui";
|
||||
import { AppSwitchMenuItems } from "@app/components/shared/AppSwitch";
|
||||
import { LogoIcon } from "@app/components/shared/LogoIcon";
|
||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Shared/Brand marks",
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
const Row = ({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1.25rem",
|
||||
padding: "0.75rem 0",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 190,
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--c-text-muted)",
|
||||
fontFamily: "var(--font-mono, monospace)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** The wordmark, at rest and muted. Both swap asset with the theme, so switch
|
||||
* the toolbar theme to check the dark pairing. */
|
||||
export const WordmarkVariants: StoryObj = {
|
||||
render: () => (
|
||||
<div>
|
||||
<Row label="default">
|
||||
<Wordmark alt="Stirling PDF" style={{ height: 28 }} />
|
||||
</Row>
|
||||
<Row label="muted">
|
||||
<Wordmark muted alt="Stirling PDF" style={{ height: 28 }} />
|
||||
</Row>
|
||||
<Row label="small">
|
||||
<Wordmark alt="Stirling PDF" style={{ height: 18 }} />
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** The icon-only mark, at the sizes the chrome uses it. */
|
||||
export const LogoIconSizes: StoryObj = {
|
||||
render: () => (
|
||||
<div>
|
||||
<Row label="16">
|
||||
<LogoIcon alt="Stirling" style={{ height: 16 }} />
|
||||
</Row>
|
||||
<Row label="24">
|
||||
<LogoIcon alt="Stirling" style={{ height: 24 }} />
|
||||
</Row>
|
||||
<Row label="40">
|
||||
<LogoIcon alt="Stirling" style={{ height: 40 }} />
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** The sidebar toggle. `mirrored` points it at the opposite edge, so the same
|
||||
* glyph serves a left and a right rail. */
|
||||
export const SidebarToggle: StoryObj = {
|
||||
render: () => (
|
||||
<div>
|
||||
<Row label="default">
|
||||
<SidebarToggleIcon />
|
||||
</Row>
|
||||
<Row label="mirrored">
|
||||
<SidebarToggleIcon mirrored />
|
||||
</Row>
|
||||
<Row label="size 28">
|
||||
<SidebarToggleIcon size={28} />
|
||||
</Row>
|
||||
<Row label="mirrored, size 28">
|
||||
<SidebarToggleIcon mirrored size={28} />
|
||||
</Row>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Switching between the editor and the processor. `AppSwitchMenuItems` is a
|
||||
* fragment of dropdown items rather than a standalone menu, so it is shown in
|
||||
* the Dropdown its callers mount it in. The app you are already in shows as
|
||||
* current and is not offered as a destination. */
|
||||
function AppSwitchDemo({ current }: { current: "editor" | "processor" }) {
|
||||
return (
|
||||
<Dropdown.Root defaultOpen>
|
||||
<Dropdown.Trigger>
|
||||
<Button variant="tertiary">Switch app</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu>
|
||||
<AppSwitchMenuItems current={current} onSwitch={() => {}} />
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export const AppSwitchFromEditor: StoryObj = {
|
||||
render: () => <AppSwitchDemo current="editor" />,
|
||||
};
|
||||
|
||||
/** The same menu seen from the processor. */
|
||||
export const AppSwitchFromProcessor: StoryObj = {
|
||||
render: () => <AppSwitchDemo current="processor" />,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import CardSelector from "@app/components/shared/CardSelector";
|
||||
import {
|
||||
METHOD_OPTIONS,
|
||||
type MethodOption,
|
||||
type SplitMethod,
|
||||
} from "@app/constants/splitConstants";
|
||||
|
||||
/**
|
||||
* A stack of choice cards, each labelled from an i18n prefix + name pair.
|
||||
* Driven here by the Split tool's real method options rather than invented
|
||||
* keys, so the labels are the ones users actually see and the story does not
|
||||
* introduce translation keys that have to be maintained.
|
||||
*/
|
||||
const meta: Meta<typeof CardSelector<SplitMethod, MethodOption>> = {
|
||||
title: "Shared/CardSelector",
|
||||
component: CardSelector,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onSelect: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof CardSelector<SplitMethod, MethodOption>>;
|
||||
|
||||
/** Every split method. */
|
||||
export const Default: Story = { args: { options: METHOD_OPTIONS } };
|
||||
|
||||
/** A short list — two choices. */
|
||||
export const FewOptions: Story = {
|
||||
args: { options: METHOD_OPTIONS.slice(0, 2) },
|
||||
};
|
||||
|
||||
/** A single choice, where the selector is really just a confirmation. */
|
||||
export const SingleOption: Story = {
|
||||
args: { options: METHOD_OPTIONS.slice(0, 1) },
|
||||
};
|
||||
|
||||
/** Inert while the tool is busy or its endpoint is still resolving. */
|
||||
export const Disabled: Story = {
|
||||
args: { options: METHOD_OPTIONS, disabled: true },
|
||||
};
|
||||
|
||||
/** Nothing available — e.g. every method needs an endpoint that is switched
|
||||
* off. */
|
||||
export const Empty: Story = { args: { options: [] } };
|
||||
@@ -57,6 +57,20 @@ const CardSelector = <T, K extends CardOption<T>>({
|
||||
radius="md"
|
||||
w="100%"
|
||||
h={"2.8rem"}
|
||||
// A choice card is a control: without these it is a div with an
|
||||
// onClick, so the option cannot be reached or chosen by keyboard.
|
||||
// aria-disabled rather than removal keeps the option visible and
|
||||
// explains why it is inert.
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-disabled={disabled || undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleOptionClick(option.value);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
backgroundColor: "var(--mantine-color-gray-2)",
|
||||
|
||||
@@ -134,7 +134,11 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||
zIndex={zIndex}
|
||||
>
|
||||
<Popover.Target>
|
||||
{/* A real button: Popover.Target stamps aria-haspopup/aria-expanded on
|
||||
its child, and those are only permitted on an actual control. */}
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
style={{
|
||||
border:
|
||||
"light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))",
|
||||
@@ -142,6 +146,9 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
||||
padding: "8px 12px",
|
||||
backgroundColor:
|
||||
"light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))",
|
||||
color: "inherit",
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
minHeight: "36px",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useId, useState, useRef, useEffect } from "react";
|
||||
import { PasswordInput, Group, Tooltip, TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
@@ -32,6 +32,7 @@ export default function EditableSecretField({
|
||||
error,
|
||||
}: EditableSecretFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [tempValue, setTempValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -66,6 +67,7 @@ export default function EditableSecretField({
|
||||
<div>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 4,
|
||||
@@ -91,7 +93,13 @@ export default function EditableSecretField({
|
||||
{isMasked && !isEditing ? (
|
||||
// Masked value from backend: show display + Edit button
|
||||
<Group gap="xs" align="flex-end">
|
||||
<TextInput value="••••••••" disabled style={{ flex: 1 }} readOnly />
|
||||
<TextInput
|
||||
id={fieldId}
|
||||
value="••••••••"
|
||||
disabled
|
||||
style={{ flex: 1 }}
|
||||
readOnly
|
||||
/>
|
||||
<Tooltip label={t("editSecret")} withArrow>
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
@@ -110,6 +118,7 @@ export default function EditableSecretField({
|
||||
) : isEditing ? (
|
||||
// Edit mode: normal password input
|
||||
<PasswordInput
|
||||
id={fieldId}
|
||||
ref={inputRef}
|
||||
value={tempValue}
|
||||
onChange={(e) => setTempValue(e.currentTarget.value)}
|
||||
@@ -125,6 +134,7 @@ export default function EditableSecretField({
|
||||
) : (
|
||||
// Normal password input: empty or user typing
|
||||
<PasswordInput
|
||||
id={fieldId}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -73,7 +73,7 @@ const EncryptedPdfUnlockModal = ({
|
||||
autoFocus
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<Text c="red" size="sm">
|
||||
<Text c="var(--color-red-dark)" size="sm">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default class ErrorBoundary extends React.Component<
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<Text size="lg" fw={500} c="red">
|
||||
<Text size="lg" fw={500} c="var(--color-red-dark)">
|
||||
Something went wrong
|
||||
</Text>
|
||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
||||
|
||||
@@ -34,7 +34,12 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="30rem">
|
||||
<Menu.Target>
|
||||
{/* Menu.Target stamps aria-haspopup/aria-expanded on its child; those are
|
||||
only permitted once the element declares a control role. It stays a
|
||||
div because it renders inside the workbench SegmentedControl's
|
||||
<label>, which may not contain interactive content. */}
|
||||
<div
|
||||
role="button"
|
||||
style={{ ...viewOptionStyle, cursor: "pointer", maxWidth: "100%" }}
|
||||
>
|
||||
{switchingTo === "viewer" ? (
|
||||
|
||||
@@ -91,6 +91,7 @@ const FileGrid = ({
|
||||
|
||||
{showSort && (
|
||||
<Select
|
||||
aria-label={t("fileManager.sortBy", "Sort files")}
|
||||
data={[
|
||||
{
|
||||
value: "date",
|
||||
|
||||
@@ -189,6 +189,13 @@ const FilePickerModal = ({
|
||||
checked={isSelected}
|
||||
onChange={() => toggleFileSelection(fileId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t(
|
||||
"fileUpload.selectFile",
|
||||
"Select {{name}}",
|
||||
{
|
||||
name: file.name,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Thumbnail */}
|
||||
@@ -239,7 +246,7 @@ const FilePickerModal = ({
|
||||
|
||||
{/* Selection summary */}
|
||||
{selectedFileIds.length > 0 && (
|
||||
<Text size="sm" c="blue" ta="center">
|
||||
<Text size="sm" c="var(--c-accent-text)" ta="center">
|
||||
{selectedFileIds.length}{" "}
|
||||
{t("fileManager.filesSelected", "files selected")}
|
||||
</Text>
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
|
||||
.slimTabUpload:hover:not(:disabled) {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
color: var(--c-accent-text);
|
||||
background: color-mix(in srgb, var(--mantine-color-blue-1) 35%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -404,6 +404,7 @@ export function FileSelectorPicker({
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-disabled={disabled || undefined}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
|
||||
@@ -129,14 +129,14 @@
|
||||
}
|
||||
|
||||
.file-sidebar-drop-overlay-icon {
|
||||
color: var(--mantine-color-blue-6, var(--c-primary)) !important;
|
||||
color: var(--c-accent-text) !important;
|
||||
font-size: 28px !important;
|
||||
}
|
||||
|
||||
.file-sidebar-drop-overlay-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-blue-6, var(--c-primary));
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* ---- Search row ---- */
|
||||
@@ -605,7 +605,7 @@
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--mantine-color-blue-6, var(--c-primary));
|
||||
background-color: var(--c-accent-text);
|
||||
color: var(--c-text-on-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.selected .file-sidebar-file-name {
|
||||
color: var(--c-accent-fg);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.file-sidebar-file-meta-row {
|
||||
@@ -208,7 +208,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* ---- Folder membership tags ---- */
|
||||
@@ -302,7 +302,7 @@
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.viewed .file-sidebar-file-name {
|
||||
color: var(--c-success);
|
||||
color: var(--color-green-dark);
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.viewed .file-sidebar-file-check {
|
||||
@@ -321,7 +321,7 @@
|
||||
/* Always show eye for the currently viewed file */
|
||||
.file-sidebar-file-item.viewed .file-sidebar-eye-btn {
|
||||
opacity: 1;
|
||||
color: var(--c-success);
|
||||
color: var(--color-green-dark);
|
||||
}
|
||||
|
||||
.file-sidebar-eye-btn:hover {
|
||||
|
||||
@@ -74,3 +74,32 @@ export const WithDisabledAction: Story = {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Seated outside the card's edge rather than inside it. */
|
||||
export const OutsidePosition: Story = {
|
||||
args: { show: true, actions, position: "outside" },
|
||||
};
|
||||
|
||||
/** An action hidden outright — dropped rather than greyed. */
|
||||
export const WithHiddenAction: Story = {
|
||||
args: {
|
||||
show: true,
|
||||
actions: [actions[0], { ...actions[1], hidden: true }, actions[2]],
|
||||
},
|
||||
};
|
||||
|
||||
/** Every action hidden: the menu renders nothing at all, so a card with no
|
||||
* available actions gets no empty affordance. */
|
||||
export const AllHidden: Story = {
|
||||
args: { show: true, actions: actions.map((a) => ({ ...a, hidden: true })) },
|
||||
};
|
||||
|
||||
/** A single action. */
|
||||
export const SingleAction: Story = {
|
||||
args: { show: true, actions: [actions[2]] },
|
||||
};
|
||||
|
||||
/** Revealed by CSS hover rather than React state — hover the card. */
|
||||
export const CssHoverVisibility: Story = {
|
||||
args: { show: false, actions, visibility: "cssHover" },
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ const toneStyles: Record<
|
||||
warning: {
|
||||
background: "var(--mantine-color-orange-0)",
|
||||
border: "var(--mantine-color-orange-3)",
|
||||
text: "var(--mantine-color-orange-9)",
|
||||
text: "var(--color-amber-dark)",
|
||||
icon: "var(--mantine-color-orange-7)",
|
||||
buttonColor: "orange",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The row of upload actions on the landing screen. Which buttons appear is
|
||||
* decided between props, the app config, and the viewport — the mobile-upload
|
||||
* affordance and the browse-files entry are both conditional — so the stories
|
||||
* vary those rather than exposing them as controls.
|
||||
*
|
||||
* The wording and icons come from the file-action hooks, which desktop builds
|
||||
* override; these render the web variants.
|
||||
*/
|
||||
import { useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LandingActions } from "@app/components/shared/LandingActions";
|
||||
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
|
||||
import {
|
||||
FilesModalContext,
|
||||
type FilesModalContextType,
|
||||
} from "@app/contexts/FilesModalContext";
|
||||
|
||||
/** Enough of the config for the actions to decide what to offer. */
|
||||
const CONFIG = { storageEnabled: false, enableMobileUpload: true };
|
||||
|
||||
function Harness(config: Partial<typeof CONFIG>) {
|
||||
return function Wrapped() {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
return (
|
||||
<AppConfigProvider
|
||||
initialConfig={{ ...CONFIG, ...config } as never}
|
||||
bootstrapMode="non-blocking"
|
||||
autoFetch={false}
|
||||
>
|
||||
{/* Only openFilesModal is read. The real provider reaches FileContext
|
||||
and NavigationContext, so a slice is supplied instead. */}
|
||||
<FilesModalContext.Provider
|
||||
value={
|
||||
{ openFilesModal: () => {} } as unknown as FilesModalContextType
|
||||
}
|
||||
>
|
||||
<LandingActions
|
||||
fileInputRef={fileInputRef}
|
||||
onUploadClick={() => {}}
|
||||
onMobileUploadClick={() => {}}
|
||||
onFileSelect={() => {}}
|
||||
/>
|
||||
</FilesModalContext.Provider>
|
||||
</AppConfigProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const meta: Meta<typeof LandingActions> = {
|
||||
title: "Shared/LandingActions",
|
||||
component: LandingActions,
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof LandingActions>;
|
||||
|
||||
export const Default: Story = { render: Harness({}) };
|
||||
|
||||
/** With storage on, the actions also offer the stored-files browser. */
|
||||
export const StorageEnabled: Story = {
|
||||
render: Harness({ storageEnabled: true }),
|
||||
};
|
||||
|
||||
/** Mobile upload off removes the phone affordance entirely. */
|
||||
export const NoMobileUpload: Story = {
|
||||
render: Harness({ enableMobileUpload: false }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow viewports take the mobile branch, which reflows the row and swaps
|
||||
* some buttons for icon-only controls.
|
||||
*/
|
||||
export const Mobile: Story = {
|
||||
render: Harness({}),
|
||||
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||
};
|
||||
|
||||
export const MobileWithStorage: Story = {
|
||||
render: Harness({ storageEnabled: true }),
|
||||
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||
};
|
||||
@@ -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
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* The zoom controls that appear in the workbench bar while the viewer is the
|
||||
* active workbench. They render nothing anywhere else, so every story here has
|
||||
* to say the workbench is "viewer".
|
||||
*
|
||||
* Zoom is pushed at the component rather than pulled: it seeds from
|
||||
* getZoomState() and then subscribes for updates, so the fixture below hands
|
||||
* back a fixed level and a subscription that never fires. The Live story wires
|
||||
* the subscription up properly so the slider and buttons actually move.
|
||||
*/
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ViewerInlineControls } from "@app/components/shared/ViewerInlineControls";
|
||||
import {
|
||||
ViewerContext,
|
||||
type ViewerContextType,
|
||||
} from "@app/contexts/ViewerContext";
|
||||
import {
|
||||
NavigationStateContext,
|
||||
type NavigationContextStateValue,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
|
||||
/** Only `workbench` is read; the rest of navigation state is irrelevant here. */
|
||||
function navState(workbench: string) {
|
||||
return { workbench } as unknown as NavigationContextStateValue;
|
||||
}
|
||||
|
||||
/** A viewer that reports one zoom level and never publishes another. */
|
||||
function staticViewer(zoomPercent: number) {
|
||||
return {
|
||||
getZoomState: () => ({ zoomPercent }),
|
||||
registerImmediateZoomUpdate: () => () => {},
|
||||
zoomActions: {
|
||||
zoomIn: () => {},
|
||||
zoomOut: () => {},
|
||||
setZoomLevel: () => {},
|
||||
},
|
||||
} as unknown as ViewerContextType;
|
||||
}
|
||||
|
||||
function Fixture({
|
||||
zoomPercent = 100,
|
||||
workbench = "viewer",
|
||||
}: {
|
||||
zoomPercent?: number;
|
||||
workbench?: string;
|
||||
}) {
|
||||
return (
|
||||
<NavigationStateContext.Provider value={navState(workbench)}>
|
||||
<ViewerContext.Provider value={staticViewer(zoomPercent)}>
|
||||
<ViewerInlineControls />
|
||||
</ViewerContext.Provider>
|
||||
</NavigationStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof ViewerInlineControls> = {
|
||||
title: "Shared/ViewerInlineControls",
|
||||
component: ViewerInlineControls,
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ViewerInlineControls>;
|
||||
|
||||
export const Default: Story = { render: () => <Fixture /> };
|
||||
|
||||
/** The slider clamps to 20–500, so these two sit at its ends. */
|
||||
export const MinimumZoom: Story = { render: () => <Fixture zoomPercent={20} /> };
|
||||
|
||||
export const MaximumZoom: Story = {
|
||||
render: () => <Fixture zoomPercent={500} />,
|
||||
};
|
||||
|
||||
/** Levels outside the slider's range still clamp rather than overflow it. */
|
||||
export const BeyondSliderRange: Story = {
|
||||
render: () => <Fixture zoomPercent={900} />,
|
||||
};
|
||||
|
||||
/** Any other workbench renders nothing at all. */
|
||||
export const NotViewerWorkbench: Story = {
|
||||
render: () => <Fixture workbench="fileManager" />,
|
||||
};
|
||||
|
||||
/**
|
||||
* The controls driving real state: the zoom actions publish through the same
|
||||
* subscription the component registers, which is how the app wires them.
|
||||
*/
|
||||
export const Live: Story = {
|
||||
render: function Live() {
|
||||
const [zoom, setZoom] = useState(100);
|
||||
const listener = useRef<((pct: number) => void) | null>(null);
|
||||
|
||||
const publish = useCallback((pct: number) => {
|
||||
const clamped = Math.min(Math.max(pct, 20), 500);
|
||||
setZoom(clamped);
|
||||
listener.current?.(clamped);
|
||||
}, []);
|
||||
|
||||
const viewer = useMemo(
|
||||
() =>
|
||||
({
|
||||
getZoomState: () => ({ zoomPercent: zoom }),
|
||||
registerImmediateZoomUpdate: (cb: (pct: number) => void) => {
|
||||
listener.current = cb;
|
||||
return () => {
|
||||
listener.current = null;
|
||||
};
|
||||
},
|
||||
zoomActions: {
|
||||
zoomIn: () => publish(zoom + 25),
|
||||
zoomOut: () => publish(zoom - 25),
|
||||
setZoomLevel: (level: number) => publish(level * 100),
|
||||
},
|
||||
// Rebuilt per zoom so the seed value stays current.
|
||||
}) as unknown as ViewerContextType,
|
||||
[zoom, publish],
|
||||
);
|
||||
|
||||
return (
|
||||
<NavigationStateContext.Provider value={navState("viewer")}>
|
||||
<ViewerContext.Provider value={viewer}>
|
||||
<ViewerInlineControls />
|
||||
</ViewerContext.Provider>
|
||||
</NavigationStateContext.Provider>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -62,6 +62,9 @@ export function ViewerInlineControls() {
|
||||
track: { height: 3 },
|
||||
}}
|
||||
label={null}
|
||||
// The thumb carries role="slider"; thumbLabel is what names it, and
|
||||
// Slider writes an empty aria-label over anything else.
|
||||
thumbLabel={t("viewer.zoomLevel", "Zoom level")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* How the page number looks: margin, size, face, zero padding and the optional
|
||||
* text wrapped around it. This is the other half of the Add Page Numbers
|
||||
* settings — the position panel places the number, this one styles it — and
|
||||
* every parameter here is one that panel does not render.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AddPageNumbersAppearanceSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings";
|
||||
import {
|
||||
defaultParameters,
|
||||
type AddPageNumbersParameters,
|
||||
} from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters";
|
||||
|
||||
function Demo({
|
||||
overrides,
|
||||
disabled,
|
||||
}: {
|
||||
overrides?: Partial<AddPageNumbersParameters>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [parameters, setParameters] = useState<AddPageNumbersParameters>({
|
||||
...defaultParameters,
|
||||
...overrides,
|
||||
});
|
||||
return (
|
||||
<AddPageNumbersAppearanceSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={(key, value) =>
|
||||
setParameters((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof AddPageNumbersAppearanceSettings> = {
|
||||
title: "Tools/AddPageNumbers/AppearanceSettings",
|
||||
component: AddPageNumbersAppearanceSettings,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AddPageNumbersAppearanceSettings>;
|
||||
|
||||
/** Defaults: medium margin, 12pt Times, no padding, no custom text. */
|
||||
export const Default: Story = { render: () => <Demo /> };
|
||||
|
||||
/** The margin extremes, which decide how far the number sits from the edge. */
|
||||
export const SmallMargin: Story = {
|
||||
render: () => <Demo overrides={{ customMargin: "small" }} />,
|
||||
};
|
||||
|
||||
export const ExtraLargeMargin: Story = {
|
||||
render: () => <Demo overrides={{ customMargin: "x-large" }} />,
|
||||
};
|
||||
|
||||
export const CourierFace: Story = {
|
||||
render: () => <Demo overrides={{ fontType: "Courier" }} />,
|
||||
};
|
||||
|
||||
export const LargeType: Story = {
|
||||
render: () => <Demo overrides={{ fontSize: 24 }} />,
|
||||
};
|
||||
|
||||
/** Padding to a fixed width, for documents whose numbers must sort as text. */
|
||||
export const ZeroPadded: Story = {
|
||||
render: () => <Demo overrides={{ zeroPad: 3 }} />,
|
||||
};
|
||||
|
||||
/** `{n}` is the placeholder the number is substituted into. */
|
||||
export const CustomText: Story = {
|
||||
render: () => <Demo overrides={{ customText: "Page {n}" }} />,
|
||||
};
|
||||
|
||||
/** Everything at once, which is where the fields crowd if they are going to. */
|
||||
export const FullyCustomised: Story = {
|
||||
render: () => (
|
||||
<Demo
|
||||
overrides={{
|
||||
customMargin: "large",
|
||||
fontType: "Helvetica",
|
||||
fontSize: 18,
|
||||
zeroPad: 4,
|
||||
customText: "Section 3 — page {n}",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/** Disabled while the tool is running. */
|
||||
export const Disabled: Story = { render: () => <Demo disabled /> };
|
||||
+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 /> };
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Where the page number lands, and what it says. The position is a 1–9 grid
|
||||
* read like a numeric keypad — 1 is bottom-left, 9 is top-right — so the
|
||||
* stories walk the corners rather than exposing position as a control.
|
||||
*
|
||||
* With no file the preview falls back to a blank page outline; passing a real
|
||||
* document is what makes it render a thumbnail, which stories cannot do.
|
||||
*
|
||||
* The preview reads only the position and the page range, so the appearance
|
||||
* parameters (font, margin, custom text, zero padding) are deliberately not
|
||||
* varied here — this panel does not render them, and a story that set them
|
||||
* would be indistinguishable from the default.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import AddPageNumbersPositionSettings from "@app/components/tools/addPageNumbers/AddPageNumbersPositionSettings";
|
||||
import {
|
||||
defaultParameters,
|
||||
type AddPageNumbersParameters,
|
||||
} from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters";
|
||||
|
||||
function Demo({
|
||||
overrides,
|
||||
disabled,
|
||||
showQuickGrid,
|
||||
}: {
|
||||
overrides?: Partial<AddPageNumbersParameters>;
|
||||
disabled?: boolean;
|
||||
showQuickGrid?: boolean;
|
||||
}) {
|
||||
const [parameters, setParameters] = useState<AddPageNumbersParameters>({
|
||||
...defaultParameters,
|
||||
...overrides,
|
||||
});
|
||||
return (
|
||||
<AddPageNumbersPositionSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={(key, value) =>
|
||||
setParameters((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
disabled={disabled}
|
||||
showQuickGrid={showQuickGrid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof AddPageNumbersPositionSettings> = {
|
||||
title: "Tools/AddPageNumbers/PositionSettings",
|
||||
component: AddPageNumbersPositionSettings,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AddPageNumbersPositionSettings>;
|
||||
|
||||
/** Bottom centre — where numbering conventionally sits, and the default. */
|
||||
export const Default: Story = { render: () => <Demo /> };
|
||||
|
||||
export const TopLeft: Story = {
|
||||
render: () => <Demo overrides={{ position: 7 }} />,
|
||||
};
|
||||
|
||||
export const TopRight: Story = {
|
||||
render: () => <Demo overrides={{ position: 9 }} />,
|
||||
};
|
||||
|
||||
export const BottomLeft: Story = {
|
||||
render: () => <Demo overrides={{ position: 1 }} />,
|
||||
};
|
||||
|
||||
/** Numbering that starts partway through, for a document split across files. */
|
||||
export const StartingNumber: Story = {
|
||||
render: () => <Demo overrides={{ startingNumber: 42 }} />,
|
||||
};
|
||||
|
||||
/** A range rather than the whole document. */
|
||||
export const PageRange: Story = {
|
||||
render: () => <Demo overrides={{ pagesToNumber: "2-8,10" }} />,
|
||||
};
|
||||
|
||||
/** Without the quick grid the position is set from the preview alone. */
|
||||
export const NoQuickGrid: Story = {
|
||||
render: () => <Demo showQuickGrid={false} />,
|
||||
};
|
||||
|
||||
/** Disabled while the tool is running. */
|
||||
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 /> };
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Placement and formatting for the stamp tool. The three formatting controls —
|
||||
* size, rotation, opacity — share one slider, and `_activePill` decides which
|
||||
* of them is showing, so each pill gets its own story rather than a control.
|
||||
*
|
||||
* Position is the same 1–9 keypad grid the page-number tool uses.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import StampPositionFormattingSettings from "@app/components/tools/addStamp/StampPositionFormattingSettings";
|
||||
import {
|
||||
defaultParameters,
|
||||
type AddStampParameters,
|
||||
} from "@app/components/tools/addStamp/useAddStampParameters";
|
||||
|
||||
function Demo({
|
||||
overrides,
|
||||
disabled,
|
||||
showPositionGrid = true,
|
||||
}: {
|
||||
overrides?: Partial<AddStampParameters>;
|
||||
disabled?: boolean;
|
||||
showPositionGrid?: boolean;
|
||||
}) {
|
||||
const [parameters, setParameters] = useState<AddStampParameters>({
|
||||
...defaultParameters,
|
||||
...overrides,
|
||||
});
|
||||
return (
|
||||
<StampPositionFormattingSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={(key, value) =>
|
||||
setParameters((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
disabled={disabled}
|
||||
showPositionGrid={showPositionGrid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof StampPositionFormattingSettings> = {
|
||||
title: "Tools/AddStamp/PositionFormattingSettings",
|
||||
component: StampPositionFormattingSettings,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof StampPositionFormattingSettings>;
|
||||
|
||||
export const Default: Story = { render: () => <Demo /> };
|
||||
|
||||
/** The formatting pills each swap in a different slider. */
|
||||
export const RotationActive: Story = {
|
||||
render: () => <Demo overrides={{ _activePill: "rotation" }} />,
|
||||
};
|
||||
|
||||
export const OpacityActive: Story = {
|
||||
render: () => <Demo overrides={{ _activePill: "opacity" }} />,
|
||||
};
|
||||
|
||||
/** An image stamp has no font size, so that control becomes a scale instead. */
|
||||
export const ImageStamp: Story = {
|
||||
render: () => <Demo overrides={{ stampType: "image" }} />,
|
||||
};
|
||||
|
||||
export const ImageStampRotated: Story = {
|
||||
render: () => (
|
||||
<Demo overrides={{ stampType: "image", _activePill: "rotation" }} />
|
||||
),
|
||||
};
|
||||
|
||||
/** Corners of the placement grid. */
|
||||
export const TopLeftPosition: Story = {
|
||||
render: () => <Demo overrides={{ position: 7 }} />,
|
||||
};
|
||||
|
||||
export const CentrePosition: Story = {
|
||||
render: () => <Demo overrides={{ position: 5 }} />,
|
||||
};
|
||||
|
||||
/** Values at the ends of their ranges, where the sliders sit hard over. */
|
||||
export const FullyRotated: Story = {
|
||||
render: () => <Demo overrides={{ _activePill: "rotation", rotation: 180 }} />,
|
||||
};
|
||||
|
||||
export const NearlyTransparent: Story = {
|
||||
render: () => <Demo overrides={{ _activePill: "opacity", opacity: 10 }} />,
|
||||
};
|
||||
|
||||
/** Automation drives position numerically, so the grid is hidden there. */
|
||||
export const WithoutPositionGrid: Story = {
|
||||
render: () => <Demo showPositionGrid={false} />,
|
||||
};
|
||||
|
||||
/** Disabled while the tool is running. */
|
||||
export const Disabled: Story = { render: () => <Demo disabled /> };
|
||||
+21
-11
@@ -34,6 +34,16 @@ const StampPositionFormattingSettings = ({
|
||||
}: StampPositionFormattingSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Each formatting pill shows a number field and a slider driving the same
|
||||
// value. The heading above them is plain text, so both controls carry the
|
||||
// label themselves rather than being left unnamed.
|
||||
const sizeLabel =
|
||||
parameters.stampType === "image"
|
||||
? t("AddStampRequest.imageSize", "Image Size")
|
||||
: t("AddStampRequest.fontSize", "Font Size");
|
||||
const rotationLabel = t("AddStampRequest.rotation", "Rotation");
|
||||
const opacityLabel = t("AddStampRequest.opacity", "Opacity");
|
||||
|
||||
return (
|
||||
<Stack gap="md" justify="space-between">
|
||||
{/* Position Grid - shown in automation settings */}
|
||||
@@ -144,11 +154,7 @@ const StampPositionFormattingSettings = ({
|
||||
{/* Single slider bound to selected pill */}
|
||||
{parameters._activePill === "fontSize" && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>
|
||||
{parameters.stampType === "image"
|
||||
? t("AddStampRequest.imageSize", "Image Size")
|
||||
: t("AddStampRequest.fontSize", "Font Size")}
|
||||
</Text>
|
||||
<Text className={styles.labelText}>{sizeLabel}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.fontSize}
|
||||
@@ -164,6 +170,7 @@ const StampPositionFormattingSettings = ({
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={disabled}
|
||||
aria-label={sizeLabel}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.fontSize}
|
||||
@@ -173,15 +180,14 @@ const StampPositionFormattingSettings = ({
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
disabled={disabled}
|
||||
thumbLabel={sizeLabel}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{parameters._activePill === "rotation" && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>
|
||||
{t("AddStampRequest.rotation", "Rotation")}
|
||||
</Text>
|
||||
<Text className={styles.labelText}>{rotationLabel}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.rotation}
|
||||
@@ -195,6 +201,7 @@ const StampPositionFormattingSettings = ({
|
||||
className={styles.numberInput}
|
||||
hideControls
|
||||
disabled={disabled}
|
||||
aria-label={rotationLabel}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.rotation}
|
||||
@@ -203,15 +210,15 @@ const StampPositionFormattingSettings = ({
|
||||
max={180}
|
||||
step={1}
|
||||
className={styles.sliderWide}
|
||||
disabled={disabled}
|
||||
thumbLabel={rotationLabel}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{parameters._activePill === "opacity" && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>
|
||||
{t("AddStampRequest.opacity", "Opacity")}
|
||||
</Text>
|
||||
<Text className={styles.labelText}>{opacityLabel}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.opacity}
|
||||
@@ -224,6 +231,7 @@ const StampPositionFormattingSettings = ({
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={disabled}
|
||||
aria-label={opacityLabel}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.opacity}
|
||||
@@ -232,6 +240,8 @@ const StampPositionFormattingSettings = ({
|
||||
max={100}
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
disabled={disabled}
|
||||
thumbLabel={opacityLabel}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user