mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
a11y: empty the grandfathered Storybook baseline (1,058 → 0) (#7309)
## What Empties the light Storybook accessibility baseline — **1,058 grandfathered violations across 846 stories → 0** — so a new violation fails the gate instead of being silently absorbed. Also burns the dark baseline **812 → 56**; every entry left is one `main` already grandfathers. ## The defect, repeated everywhere A colour picked as a **fill**, chosen to carry a white label at 3:1, reused as **text**, where the floor is 4.5:1. It recurred through status accents, filled buttons, form labels, Mantine's light and outline variants, CSS declarations, inline styles and the generated accent ramp. Three systemic causes account for most of it: - **Mantine's semantic slots were never bound.** `-text`, `-outline`, `-light-color`, `-filled` and `-dimmed` all default to the hue's solid fill. Both resolvers now pin them to the accessible ink for the active scheme. - **The tint ladder was compressed.** `--color-<hue>-50/100/200` pointed at saturated 400-level primitives, so every "tint" background rendered as a fill. - **Text was faded with `opacity`**, pushing already-muted copy below the floor. Each site now recedes via ink or surface, which is what conveyed the state anyway. ## Dark mode The colour resolver's dark half was empty, so dark fell through to Mantine's stock palette — and fixing the naming violations unmasked the contrast sitting underneath them. Both schemes now share one slot map, since most slots are written in tokens that already flip. The dark-only fixes: `--c-text-subtle` (3.0:1, used in 478 places), the error and section-label inks, and the accent ramp's text step — which light reaches by mixing toward black and dark has to reach by mixing toward white. ## Also - New `--c-*-solid` tokens for fills that must carry a white label, distinct from the `--c-<tone>` values used for surfaces, borders and icons. - A `data-user-content-preview` opt-out for nodes rendering a facsimile of the user's own document — WCAG governs the interface, not content authored through it. ## Verification - `task frontend:check:all` — green. - Changed-set gate, both schemes, after the final rebase: **366 stories, 0 regressions**. - Full sweep at the prior base — light **1,447 stories / 0 violations**, dark **1,448 / 0 regressions**. The dark re-record was confirmed key-by-key to be a strict subset of `main`'s, so nothing new is grandfathered. Roughly 28% of what this clears is naming and structure (`button-name`, `label`, `aria-*`) and has no visual signature; the rest is contrast.
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@
|
||||
// embedded interpreter runs on Windows, but sed/grep/sort do not exist for
|
||||
// developers calling tasks from PowerShell.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdirSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
|
||||
const base = process.argv[2] || "origin/main";
|
||||
|
||||
@@ -40,9 +41,25 @@ for (const f of changed) {
|
||||
continue;
|
||||
}
|
||||
if (TEST.test(f) || !SOURCE.test(f)) continue;
|
||||
const sibling = f.replace(SOURCE, "");
|
||||
for (const s of [`${sibling}.stories.tsx`, `${sibling}.stories.ts`])
|
||||
if (existsSync(s)) stories.add(s);
|
||||
// A story file does not have to match its source's case — tokens.css sits
|
||||
// beside Tokens.stories.tsx. Deriving the name from the source and trusting
|
||||
// existsSync silently skips those on a case-sensitive filesystem, and on a
|
||||
// case-insensitive one feeds the scan a path no result will ever match. Read
|
||||
// the directory instead and compare case-insensitively, then use the name as
|
||||
// it is actually spelled on disk.
|
||||
const dir = dirname(f) || ".";
|
||||
const stem = basename(f).replace(SOURCE, "").toLowerCase();
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!STORY.test(entry)) continue;
|
||||
if (entry.replace(STORY, "").toLowerCase() !== stem) continue;
|
||||
stories.add(join(dir, entry).split("\\").join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
// One line, each path quoted: the output is interpolated into a task command,
|
||||
|
||||
@@ -19,6 +19,11 @@ import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { UIProvider } from "@portal/contexts/UIContext";
|
||||
import { SuiProvider } from "@portal/theme/SuiProvider";
|
||||
import { 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";
|
||||
@@ -199,6 +204,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 =
|
||||
@@ -214,16 +250,21 @@ const withProviders: Decorator = (Story, context) => {
|
||||
// the portal's base.css keys its reset/typography on. Give portal stories
|
||||
// the same wrapper (and only them — the scoping exists precisely so portal
|
||||
// styles never apply to editor components).
|
||||
const isPortalStory = (context.parameters.fileName ?? "").includes(
|
||||
"/portal/",
|
||||
);
|
||||
// `fileName` is only injected by the dev/build pipeline — under the Vitest
|
||||
// runner it is absent, so path alone would silently drop every portal story
|
||||
// onto the editor theme (where portal-only palette entries like `amber`
|
||||
// resolve to nothing and render unstyled). The title prefix is the fallback
|
||||
// that survives both environments.
|
||||
const isPortalStory =
|
||||
(context.parameters.fileName ?? "").includes("/portal/") ||
|
||||
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}>
|
||||
@@ -241,7 +282,7 @@ const withProviders: Decorator = (Story, context) => {
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
</LinkProvider>
|
||||
</SuiProvider>
|
||||
</StoryTheme>
|
||||
</ThemeBridge>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
@@ -274,6 +315,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: {
|
||||
|
||||
@@ -3001,12 +3001,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"
|
||||
@@ -3023,6 +3026,7 @@ refresh = "Refresh"
|
||||
remaining = "Remaining"
|
||||
retry = "Retry"
|
||||
save = "Save"
|
||||
stepOf = "Step {{current}} of {{total}}"
|
||||
|
||||
[compare]
|
||||
clearSelected = "Clear selected"
|
||||
@@ -3185,6 +3189,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 +3829,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 +4175,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"
|
||||
@@ -5127,6 +5135,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."
|
||||
@@ -5563,6 +5572,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"
|
||||
|
||||
@@ -5642,6 +5652,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"
|
||||
@@ -6045,6 +6056,7 @@ small = "500 Credits"
|
||||
xsmall = "100 Credits"
|
||||
|
||||
[plan.availablePlans]
|
||||
currency = "Billing currency"
|
||||
subtitle = "Choose the plan that fits your needs"
|
||||
title = "Available Plans"
|
||||
|
||||
@@ -6280,6 +6292,7 @@ revoked = "Revoked"
|
||||
unnamed = "Unnamed instance"
|
||||
|
||||
[portal.accountLink.instances.columns]
|
||||
actions = "Actions"
|
||||
instance = "Instance"
|
||||
lastSeen = "Last seen"
|
||||
linked = "Linked"
|
||||
@@ -6624,6 +6637,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"
|
||||
@@ -7184,6 +7198,7 @@ sensitiveTitle = "Sensitive — access required"
|
||||
|
||||
[portal.documents.table.columns]
|
||||
action = "Pipeline / Action"
|
||||
actions = "Actions"
|
||||
document = "Document"
|
||||
product = "Product"
|
||||
status = "Status"
|
||||
@@ -7481,6 +7496,7 @@ rolledBack = "Rolled back"
|
||||
rolling = "Rolling out"
|
||||
|
||||
[portal.infrastructure.deployments]
|
||||
loadAria = "Load for {{name}}"
|
||||
msValue = "{{value}} ms"
|
||||
throughputValue = "{{value}}/min"
|
||||
|
||||
@@ -7526,6 +7542,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."
|
||||
|
||||
@@ -7892,6 +7909,7 @@ paused = "Paused"
|
||||
|
||||
[portal.pipelines.table]
|
||||
name = "Pipeline"
|
||||
open = "Open"
|
||||
sources = "Sources"
|
||||
status = "Status"
|
||||
steps = "Steps"
|
||||
@@ -8783,6 +8801,7 @@ unused = "Unused"
|
||||
|
||||
[portal.sources.table]
|
||||
documents = "Documents"
|
||||
open = "Open"
|
||||
source = "Source"
|
||||
status = "Status"
|
||||
usedBy = "Policies"
|
||||
|
||||
@@ -3005,12 +3005,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"
|
||||
@@ -3028,6 +3031,7 @@ refresh = "Refresh"
|
||||
remaining = "Remaining"
|
||||
retry = "Retry"
|
||||
save = "Save"
|
||||
stepOf = "Step {{current}} of {{total}}"
|
||||
|
||||
[compare]
|
||||
clearSelected = "Clear selected"
|
||||
@@ -3190,6 +3194,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."
|
||||
@@ -3833,11 +3838,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"
|
||||
@@ -4177,6 +4184,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"
|
||||
@@ -5169,6 +5177,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."
|
||||
@@ -5605,6 +5614,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"
|
||||
|
||||
@@ -5684,6 +5694,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"
|
||||
@@ -6087,6 +6098,7 @@ small = "500 Credits"
|
||||
xsmall = "100 Credits"
|
||||
|
||||
[plan.availablePlans]
|
||||
currency = "Billing currency"
|
||||
subtitle = "Choose the plan that fits your needs"
|
||||
title = "Available Plans"
|
||||
|
||||
@@ -6322,6 +6334,7 @@ revoked = "Revoked"
|
||||
unnamed = "Unnamed instance"
|
||||
|
||||
[portal.accountLink.instances.columns]
|
||||
actions = "Actions"
|
||||
instance = "Instance"
|
||||
lastSeen = "Last seen"
|
||||
linked = "Linked"
|
||||
@@ -6666,6 +6679,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"
|
||||
@@ -7559,6 +7573,7 @@ rolledBack = "Rolled back"
|
||||
rolling = "Rolling out"
|
||||
|
||||
[portal.infrastructure.deployments]
|
||||
loadAria = "Load for {{name}}"
|
||||
msValue = "{{value}} ms"
|
||||
throughputValue = "{{value}}/min"
|
||||
|
||||
@@ -7604,6 +7619,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."
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Stack, Text, useMantineTheme, alpha } from "@mantine/core";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -9,7 +9,6 @@ interface DragOverlayProps {
|
||||
|
||||
const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
@@ -21,8 +20,9 @@ const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: alpha(theme.colors.blue[6], 0.1),
|
||||
border: `0.125rem dashed ${theme.colors.blue[6]}`,
|
||||
// The prompt below is the drop affordance on its own. Tinting the whole
|
||||
// region and ringing it in dashed accent reads as a second, competing
|
||||
// surface, so the overlay stays transparent.
|
||||
borderRadius: "1.875rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -32,10 +32,12 @@ const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap="md">
|
||||
{/* Muted ink rather than the accent shade: it has to read on whatever
|
||||
the overlay happens to sit on, in either scheme. */}
|
||||
<UploadFileIcon
|
||||
style={{ fontSize: "4rem", color: theme.colors.blue[6] }}
|
||||
style={{ fontSize: "4rem", color: "var(--c-text-muted)" }}
|
||||
/>
|
||||
<Text size="xl" fw={500} c="blue.6">
|
||||
<Text size="xl" fw={500} c="var(--c-text-muted)">
|
||||
{t("fileManager.dropFilesHere", "Drop files here to upload")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -906,35 +906,47 @@ function ListView({
|
||||
|
||||
return (
|
||||
<div className="files-page-list" role="grid">
|
||||
{/* Each direct child is a columnheader: a role="row" may only own cells, so
|
||||
the sort controls and the select-all box have to sit inside one. */}
|
||||
<div className="files-page-list-row is-header" role="row">
|
||||
{onSetSelection && visibleFileIds.length > 0 ? (
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={() => {
|
||||
onSetSelection(allSelected ? new Set() : new Set(visibleFileIds));
|
||||
}}
|
||||
aria-label={
|
||||
allSelected
|
||||
? t("filesPage.deselectAll", "Clear selection")
|
||||
: t("filesPage.selectAll", "Select all")
|
||||
}
|
||||
/>
|
||||
<span role="columnheader">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={() => {
|
||||
onSetSelection(
|
||||
allSelected ? new Set() : new Set(visibleFileIds),
|
||||
);
|
||||
}}
|
||||
aria-label={
|
||||
allSelected
|
||||
? t("filesPage.deselectAll", "Clear selection")
|
||||
: t("filesPage.selectAll", "Select all")
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
<span {...headerProps("name-asc", "name-desc")}>
|
||||
{t("filesPage.column.name", "Name")}
|
||||
{sortIndicator("name-asc", "name-desc")}
|
||||
<span role="columnheader">
|
||||
<span {...headerProps("name-asc", "name-desc")}>
|
||||
{t("filesPage.column.name", "Name")}
|
||||
{sortIndicator("name-asc", "name-desc")}
|
||||
</span>
|
||||
</span>
|
||||
<span>{t("filesPage.column.type", "Type")}</span>
|
||||
<span {...headerProps("size-asc", "size-desc")}>
|
||||
{t("filesPage.column.size", "Size")}
|
||||
{sortIndicator("size-asc", "size-desc")}
|
||||
<span role="columnheader">{t("filesPage.column.type", "Type")}</span>
|
||||
<span role="columnheader">
|
||||
<span {...headerProps("size-asc", "size-desc")}>
|
||||
{t("filesPage.column.size", "Size")}
|
||||
{sortIndicator("size-asc", "size-desc")}
|
||||
</span>
|
||||
</span>
|
||||
<span {...headerProps("modified-asc", "modified-desc")}>
|
||||
{t("filesPage.column.modified", "Modified")}
|
||||
{sortIndicator("modified-asc", "modified-desc")}
|
||||
<span role="columnheader">
|
||||
<span {...headerProps("modified-asc", "modified-desc")}>
|
||||
{t("filesPage.column.modified", "Modified")}
|
||||
{sortIndicator("modified-asc", "modified-desc")}
|
||||
</span>
|
||||
</span>
|
||||
<span aria-hidden="true" />
|
||||
</div>
|
||||
@@ -1091,7 +1103,12 @@ function FolderRow({
|
||||
className={`files-page-list-row${isDropTarget ? " is-drop-target" : ""}`}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
<span style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
{/* Each direct child is a gridcell: a role="row" may only own cells, so the
|
||||
actions menu has to sit inside one. */}
|
||||
<span
|
||||
role="gridcell"
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<FolderThumbnail
|
||||
color={folder.color}
|
||||
size="row"
|
||||
@@ -1119,61 +1136,65 @@ function FolderRow({
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span>{t("filesPage.folder", "Folder")}</span>
|
||||
<span>
|
||||
<span role="gridcell">{t("filesPage.folder", "Folder")}</span>
|
||||
<span role="gridcell">
|
||||
{fileCount === 0
|
||||
? "-"
|
||||
: t("filesPage.folderItems", "{{count}} items", { count: fileCount })}
|
||||
</span>
|
||||
<span>{getFileDate({ lastModified: folder.updatedAt })}</span>
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.folderMenu", "Folder actions")}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{t("filesPage.open", "Open")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
|
||||
onClick={onRename}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.rename", "Rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>
|
||||
{t("filesPage.appearance.title", "Appearance")}
|
||||
</Menu.Label>
|
||||
<FolderAppearancePicker
|
||||
folder={folder}
|
||||
onChange={onChangeAppearance}
|
||||
disabled={!serverReachable}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={onDelete}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.deleteFolder", "Delete folder")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<span role="gridcell">
|
||||
{getFileDate({ lastModified: folder.updatedAt })}
|
||||
</span>
|
||||
<span role="gridcell">
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.folderMenu", "Folder actions")}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{t("filesPage.open", "Open")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileRenameOutlineIcon fontSize="small" />}
|
||||
onClick={onRename}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.rename", "Rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>
|
||||
{t("filesPage.appearance.title", "Appearance")}
|
||||
</Menu.Label>
|
||||
<FolderAppearancePicker
|
||||
folder={folder}
|
||||
onChange={onChangeAppearance}
|
||||
disabled={!serverReachable}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={onDelete}
|
||||
disabled={!serverReachable}
|
||||
title={!serverReachable ? offlineHint : undefined}
|
||||
>
|
||||
{t("filesPage.deleteFolder", "Delete folder")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1254,35 +1275,40 @@ function FileRow({
|
||||
isInWorkspace ? " is-in-workspace" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Checkbox only shows in multi-select mode (see FileCard). When the
|
||||
checkbox is hidden the first grid column collapses, but the row's
|
||||
CSS grid keeps the columns aligned via the named template, so no
|
||||
empty cell shows. */}
|
||||
{/* Each direct child is a gridcell: a role="row" may only own cells, so the
|
||||
checkbox and the actions menu have to sit inside one.
|
||||
|
||||
The checkbox only shows in multi-select mode (see FileCard). When it is
|
||||
hidden the first grid column collapses, but the row's CSS grid keeps the
|
||||
columns aligned via the named template, so no empty cell shows. */}
|
||||
{multiSelectActive ? (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onClick={(e) => {
|
||||
// Toggle this file in/out of the selection without modifier keys.
|
||||
e.stopPropagation();
|
||||
onClick({
|
||||
...e,
|
||||
shiftKey: false,
|
||||
ctrlKey: true,
|
||||
metaKey: true,
|
||||
} as unknown as React.MouseEvent);
|
||||
}}
|
||||
onChange={() => {
|
||||
/* handled by onClick */
|
||||
}}
|
||||
aria-label={t("filesPage.selectFile", "Select file {{name}}", {
|
||||
name: file.name,
|
||||
})}
|
||||
/>
|
||||
<span role="gridcell">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onClick={(e) => {
|
||||
// Toggle this file in/out of the selection without modifier keys.
|
||||
e.stopPropagation();
|
||||
onClick({
|
||||
...e,
|
||||
shiftKey: false,
|
||||
ctrlKey: true,
|
||||
metaKey: true,
|
||||
} as unknown as React.MouseEvent);
|
||||
}}
|
||||
onChange={() => {
|
||||
/* handled by onClick */
|
||||
}}
|
||||
aria-label={t("filesPage.selectFile", "Select file {{name}}", {
|
||||
name: file.name,
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
// Empty cell preserves grid column alignment.
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
<span
|
||||
role="gridcell"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -1342,94 +1368,96 @@ function FileRow({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{ext || t("filesPage.file", "File")}</span>
|
||||
<span>{fileSize}</span>
|
||||
<span>{fileDate}</span>
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.fileMenu", "File actions")}
|
||||
data-testid="file-card-actions"
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.addToWorkspace", "Add to workspace")}
|
||||
</Menu.Item>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
{/* Per-file Save to server; shown for local-only files. When
|
||||
storage is off it stays visible but disabled with a tooltip. */}
|
||||
{onSaveToServer && file.remoteStorageId == null && (
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
position="left"
|
||||
multiline
|
||||
w={240}
|
||||
<span role="gridcell">{ext || t("filesPage.file", "File")}</span>
|
||||
<span role="gridcell">{fileSize}</span>
|
||||
<span role="gridcell">{fileDate}</span>
|
||||
<span role="gridcell">
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
ref={kebabRef}
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("filesPage.fileMenu", "File actions")}
|
||||
data-testid="file-card-actions"
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
style={
|
||||
saveToServerDisabledReason
|
||||
? { pointerEvents: "auto" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onVersionHistory && (file.versionNumber ?? 1) > 1 && (
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<HistoryIcon fontSize="small" />}
|
||||
leftSection={<OpenInNewIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVersionHistory();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.versionHistory", "Version history")}
|
||||
{t("filesPage.addToWorkspace", "Add to workspace")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.remove", "Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
{/* Per-file Save to server; shown for local-only files. When
|
||||
storage is off it stays visible but disabled with a tooltip. */}
|
||||
{onSaveToServer && file.remoteStorageId == null && (
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
position="left"
|
||||
multiline
|
||||
w={240}
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
style={
|
||||
saveToServerDisabledReason
|
||||
? { pointerEvents: "auto" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onVersionHistory && (file.versionNumber ?? 1) > 1 && (
|
||||
<Menu.Item
|
||||
leftSection={<HistoryIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVersionHistory();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.versionHistory", "Version history")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
{t("filesPage.remove", "Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ const styles = {
|
||||
},
|
||||
cloud: {
|
||||
background: "color-mix(in srgb, var(--c-primary) 16%, transparent)",
|
||||
color: "var(--c-primary)",
|
||||
color: "var(--c-accent-text)",
|
||||
},
|
||||
shared: {
|
||||
background:
|
||||
"color-mix(in srgb, var(--mantine-color-orange-6) 16%, transparent)",
|
||||
color: "var(--mantine-color-orange-6)",
|
||||
color: "var(--color-amber-dark)",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -339,6 +339,9 @@
|
||||
}
|
||||
|
||||
.files-page-list-row.is-header [data-sortable="true"] {
|
||||
/* Block so the hit area and hover tint fill the columnheader cell that wraps
|
||||
it, rather than hugging the label text. */
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin: -0.2rem -0.4rem;
|
||||
@@ -741,7 +744,7 @@
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--c-primary) 12%, transparent);
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -980,7 +983,7 @@
|
||||
.files-page-details-version-timeline-count {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@@ -1112,7 +1115,29 @@
|
||||
.files-page-details-version-timeline-expand-btn:hover span {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.files-page-details-version-timeline-delta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--c-text);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.files-page-details-version-timeline-delta.is-origin {
|
||||
font-weight: 400;
|
||||
color: var(--c-text-subtle);
|
||||
font-style: italic;
|
||||
}
|
||||
.files-page-details-version-timeline-delta-plus {
|
||||
color: var(--c-accent-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.files-page-details-version-timeline-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.files-page-details-version-timeline-chevron {
|
||||
color: var(--c-text-subtle);
|
||||
transition: transform 0.15s ease;
|
||||
@@ -1120,7 +1145,7 @@
|
||||
|
||||
.files-page-details-version-timeline-chevron.is-expanded {
|
||||
transform: rotate(180deg);
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.files-page-details-version-timeline-expanded {
|
||||
@@ -1185,7 +1210,7 @@
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
pointer-events: none;
|
||||
color: var(--c-primary);
|
||||
color: var(--c-accent-text);
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
z-index: 10;
|
||||
|
||||
@@ -151,7 +151,10 @@ export function FolderThumbnail({
|
||||
borderRadius: "999px",
|
||||
background: "var(--c-surface, #fff)",
|
||||
border: `1px solid ${accent}`,
|
||||
color: accent,
|
||||
// The ring carries the folder's accent; the numeral does not.
|
||||
// Folder colours are user-chosen and many are too light to read
|
||||
// as text on the white pill.
|
||||
color: "var(--c-text)",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
display: "inline-flex",
|
||||
|
||||
+1
-1
@@ -325,7 +325,7 @@
|
||||
|
||||
.v2Badge {
|
||||
background: var(--c-primary-tint);
|
||||
color: var(--c-accent-fg);
|
||||
color: var(--c-accent-text);
|
||||
padding: 3px 9px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -101,7 +101,10 @@ export default function OnboardingSlideShell({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
// Composed rather than the plain <Modal>, because only Modal.Content lands
|
||||
// props on the role="dialog" element — the slide draws its own title, so the
|
||||
// dialog needs an aria-label to have an accessible name.
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
closeOnClickOutside={false}
|
||||
@@ -109,7 +112,6 @@ export default function OnboardingSlideShell({
|
||||
centered
|
||||
size="lg"
|
||||
radius={20}
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
|
||||
@@ -121,106 +123,122 @@ export default function OnboardingSlideShell({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className={styles.card}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.brand}>
|
||||
<img
|
||||
src={stirlingMark}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={styles.brandLogo}
|
||||
/>
|
||||
<span className={styles.wordmark}>Stirling</span>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
{showProgress && (
|
||||
<span className={styles.stepPill}>
|
||||
{t("onboarding.stepOf", "Step {{current}} of {{total}}", {
|
||||
current: stepIndex + 1,
|
||||
total: stepCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{allowDismiss && (
|
||||
<ActionIcon
|
||||
onClick={onClose}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
aria-label={t("common.close", "Close")}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
<Modal.Overlay />
|
||||
<Modal.Content
|
||||
radius={20}
|
||||
aria-label={t("onboarding.dialogLabel", "Onboarding")}
|
||||
>
|
||||
<Modal.Body>
|
||||
<div className={styles.card}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.brand}>
|
||||
<img
|
||||
src={stirlingMark}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={styles.brandLogo}
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<span className={styles.wordmark}>Stirling</span>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
{showProgress && (
|
||||
<span className={styles.stepPill}>
|
||||
{t("onboarding.stepOf", "Step {{current}} of {{total}}", {
|
||||
current: stepIndex + 1,
|
||||
total: stepCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{allowDismiss && (
|
||||
<ActionIcon
|
||||
onClick={onClose}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
size="md"
|
||||
aria-label={t("common.close", "Close")}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="close-rounded"
|
||||
width="1.1rem"
|
||||
height="1.1rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showProgress && (
|
||||
<div
|
||||
className={styles.progressTrack}
|
||||
role="progressbar"
|
||||
aria-valuenow={stepIndex + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={stepCount}
|
||||
>
|
||||
{Array.from({ length: stepCount }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`${styles.progressSeg} ${
|
||||
index <= stepIndex ? styles.progressSegDone : ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.heroPanel}>
|
||||
<div className={styles.heroArt} key={`hero-${slideKey}`}>
|
||||
{hero}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key={`title-${slideKey}`} className={styles.titleNew}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div key={`body-${slideKey}`} className={styles.bodyNew}>
|
||||
{body}
|
||||
<style>{`.${styles.bodyNew} strong{color: var(--c-text); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{backButtons.length === 0 ? (
|
||||
<div className={styles.footerEnd}>{actions}</div>
|
||||
) : (
|
||||
<div className={styles.footerBetween}>
|
||||
<div className={styles.footerGroup}>
|
||||
{backButtons.map((button) => (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
disabled={button.disabled}
|
||||
aria-label={t("onboarding.buttons.back", "Back")}
|
||||
>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
))}
|
||||
</div>
|
||||
{actions}
|
||||
{showProgress && (
|
||||
<div
|
||||
className={styles.progressTrack}
|
||||
role="progressbar"
|
||||
aria-valuenow={stepIndex + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={stepCount}
|
||||
aria-label={t(
|
||||
"onboarding.stepOf",
|
||||
"Step {{current}} of {{total}}",
|
||||
{
|
||||
current: stepIndex + 1,
|
||||
total: stepCount,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: stepCount }, (_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`${styles.progressSeg} ${
|
||||
index <= stepIndex ? styles.progressSegDone : ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.heroPanel}>
|
||||
<div className={styles.heroArt} key={`hero-${slideKey}`}>
|
||||
{hero}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key={`title-${slideKey}`} className={styles.titleNew}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div key={`body-${slideKey}`} className={styles.bodyNew}>
|
||||
{body}
|
||||
<style>{`.${styles.bodyNew} strong{color: var(--c-text); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{backButtons.length === 0 ? (
|
||||
<div className={styles.footerEnd}>{actions}</div>
|
||||
) : (
|
||||
<div className={styles.footerBetween}>
|
||||
<div className={styles.footerGroup}>
|
||||
{backButtons.map((button) => (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
variant="tertiary"
|
||||
accent="neutral"
|
||||
disabled={button.disabled}
|
||||
aria-label={t("onboarding.buttons.back", "Back")}
|
||||
>
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
))}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function AnalyticsChoiceSlide({
|
||||
</Button>
|
||||
</div>
|
||||
{analyticsError && (
|
||||
<div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>
|
||||
<div style={{ color: "var(--color-red-dark)", marginTop: 12 }}>
|
||||
{analyticsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+3
-2
@@ -222,7 +222,8 @@
|
||||
/* Error helper text above the input */
|
||||
.errorText {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-brand-accent);
|
||||
/* The brand red is a fill; error copy takes the theme's error ink. */
|
||||
color: var(--color-red-dark);
|
||||
}
|
||||
|
||||
/* Compact error container for inline tool settings */
|
||||
@@ -237,7 +238,7 @@
|
||||
|
||||
/* Two-line clamp for compact error text */
|
||||
.errorTextClamp {
|
||||
color: var(--text-brand-accent);
|
||||
color: var(--color-red-dark);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
@@ -135,7 +135,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))",
|
||||
@@ -143,6 +147,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";
|
||||
@@ -33,6 +33,7 @@ export default function EditableSecretField({
|
||||
}: EditableSecretFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedPlaceholder = placeholder ?? t("common.enterValue");
|
||||
const fieldId = useId();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [tempValue, setTempValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -67,6 +68,7 @@ export default function EditableSecretField({
|
||||
<div>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 4,
|
||||
@@ -92,7 +94,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"
|
||||
@@ -111,6 +119,7 @@ export default function EditableSecretField({
|
||||
) : isEditing ? (
|
||||
// Edit mode: normal password input
|
||||
<PasswordInput
|
||||
id={fieldId}
|
||||
ref={inputRef}
|
||||
value={tempValue}
|
||||
onChange={(e) => setTempValue(e.currentTarget.value)}
|
||||
@@ -126,6 +135,7 @@ export default function EditableSecretField({
|
||||
) : (
|
||||
// Normal password input: empty or user typing
|
||||
<PasswordInput
|
||||
id={fieldId}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={resolvedPlaceholder}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -33,6 +33,9 @@ interface MobileTransferModalProps {
|
||||
pollingErrorMessage: string;
|
||||
/** Rendered under the QR once files have arrived (e.g. a received-count badge). */
|
||||
renderReceived?: (count: number) => ReactNode;
|
||||
/** Accessible name for the QR image. Defaults to the modal's own title, which
|
||||
* already names the feature the code belongs to. */
|
||||
qrTitle?: string;
|
||||
qrSize?: number;
|
||||
}
|
||||
|
||||
@@ -50,6 +53,7 @@ export default function MobileTransferModal({
|
||||
sessionCreateErrorMessage,
|
||||
pollingErrorMessage,
|
||||
renderReceived,
|
||||
qrTitle = title,
|
||||
qrSize = 240,
|
||||
}: MobileTransferModalProps) {
|
||||
const { config } = useAppConfig();
|
||||
@@ -134,6 +138,7 @@ export default function MobileTransferModal({
|
||||
size={qrSize}
|
||||
level="H"
|
||||
includeMargin
|
||||
title={qrTitle}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
+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}
|
||||
>
|
||||
|
||||
@@ -22,7 +22,9 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
maxWidth = "100%",
|
||||
displayStyle = "text",
|
||||
size = "xs",
|
||||
color = "var(--mantine-color-blue-7)",
|
||||
// A fixed palette shade cannot follow the colour scheme; the accent's text
|
||||
// token carries the readable step for whichever theme is active.
|
||||
color = "var(--c-accent-text)",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
if (!toolChain || toolChain.length === 0) return null;
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
color: var(--c-accent-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -79,15 +79,15 @@
|
||||
var(--mantine-color-blue-filled) 18%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* Dark mode: the subtle gray close button is too dim against the dark rail —
|
||||
brighten it to a clearly-visible light grey (near-white on hover). */
|
||||
[data-mantine-color-scheme="dark"] .sui-panelhdr__close {
|
||||
color: var(--mantine-color-gray-4);
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .sui-panelhdr__close:hover {
|
||||
color: var(--mantine-color-gray-2);
|
||||
color: var(--c-text-subtle);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,7 @@
|
||||
|
||||
.workbench-bar-view-btn.active {
|
||||
background-color: var(--c-primary-subtle);
|
||||
color: var(--c-accent-fg);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn.workbench-bar-back-btn {
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
.workbench-bar-view-btn.workbench-bar-back-btn:hover {
|
||||
background-color: color-mix(in srgb, var(--c-primary) 12%, transparent);
|
||||
color: var(--c-accent-fg);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.workbench-bar-view-btn svg {
|
||||
|
||||
@@ -18,7 +18,7 @@ const WARNING_ICON_STYLE: CSSProperties = {
|
||||
fontSize: 36,
|
||||
display: "block",
|
||||
margin: "0 auto 8px",
|
||||
color: "var(--mantine-color-blue-6)",
|
||||
color: "var(--c-accent-text)",
|
||||
};
|
||||
|
||||
const ZipWarningModal = ({
|
||||
|
||||
+25
-11
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useId, useState, useEffect } from "react";
|
||||
import {
|
||||
Paper,
|
||||
Stack,
|
||||
@@ -83,6 +83,15 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
desktopUpdateMode,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Each setting is a row of label text next to a bare control, so the controls
|
||||
// are named by pointing at that text rather than by a <label> association.
|
||||
const labelIds = useId();
|
||||
const updateModeLabelId = `${labelIds}-update-mode`;
|
||||
const viewerZoomLabelId = `${labelIds}-viewer-zoom`;
|
||||
const hideToolsLabelId = `${labelIds}-hide-tools`;
|
||||
const hideConversionsLabelId = `${labelIds}-hide-conversions`;
|
||||
const autoUnzipLabelId = `${labelIds}-auto-unzip`;
|
||||
const autoUnzipLimitLabelId = `${labelIds}-auto-unzip-limit`;
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
const { setTheme, themeMode } = useTheme();
|
||||
@@ -209,7 +218,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
icon="admin-panel-settings-rounded"
|
||||
width="1.2rem"
|
||||
height="1.2rem"
|
||||
style={{ color: "var(--mantine-color-blue-6)" }}
|
||||
style={{ color: "var(--c-accent-text)" }}
|
||||
/>
|
||||
<Text
|
||||
fw={600}
|
||||
@@ -248,7 +257,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
href="https://docs.stirlingpdf.com/Configuration/System%20and%20Security/"
|
||||
target="_blank"
|
||||
size="sm"
|
||||
style={{ color: "var(--mantine-color-blue-6)" }}
|
||||
>
|
||||
{t(
|
||||
"settings.general.enableFeatures.learnMore",
|
||||
@@ -305,7 +313,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</Text>
|
||||
{mismatchVersion && (
|
||||
<Text size="sm" c="red" mt={4}>
|
||||
<Text size="sm" c="var(--color-red-dark)" mt={4}>
|
||||
{t(
|
||||
"settings.general.updates.versionMismatch",
|
||||
"Warning: A mismatch has been detected between the client version and the AppConfig version. Using different versions can lead to compatibility issues, errors, and security risks. Please ensure that server and client are using the same version.",
|
||||
@@ -336,7 +344,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
"Latest Version",
|
||||
)}
|
||||
:{" "}
|
||||
<Text component="span" fw={500} c="blue">
|
||||
<Text component="span" fw={500} c="var(--c-accent-text)">
|
||||
{updateSummary.latest_version}
|
||||
</Text>
|
||||
</Text>
|
||||
@@ -391,7 +399,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
{desktopUpdateMode && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
<Text id={updateModeLabelId} fw={600} size="sm">
|
||||
{t(
|
||||
"settings.general.updates.updateBehavior",
|
||||
"Update behavior",
|
||||
@@ -422,6 +430,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
)}
|
||||
</Text>
|
||||
<Select
|
||||
aria-labelledby={updateModeLabelId}
|
||||
disabled={desktopUpdateMode.locked}
|
||||
value={desktopUpdateMode.mode}
|
||||
onChange={(value) => {
|
||||
@@ -622,7 +631,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
<Text id={viewerZoomLabelId} fw={500} size="sm">
|
||||
{t("settings.general.defaultViewerZoom", "Default reader zoom")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
@@ -633,6 +642,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
<Select
|
||||
aria-labelledby={viewerZoomLabelId}
|
||||
value={preferences.defaultViewerZoom}
|
||||
onChange={(val: string | null) => {
|
||||
if (val)
|
||||
@@ -677,7 +687,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
<Text id={hideToolsLabelId} fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.hideUnavailableTools",
|
||||
"Hide unavailable tools",
|
||||
@@ -691,6 +701,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
aria-labelledby={hideToolsLabelId}
|
||||
checked={preferences.hideUnavailableTools}
|
||||
onChange={(event) =>
|
||||
updatePreference(
|
||||
@@ -708,7 +719,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
<Text id={hideConversionsLabelId} fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.hideUnavailableConversions",
|
||||
"Hide unavailable conversions",
|
||||
@@ -722,6 +733,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
aria-labelledby={hideConversionsLabelId}
|
||||
checked={preferences.hideUnavailableConversions}
|
||||
onChange={(event) =>
|
||||
updatePreference(
|
||||
@@ -749,7 +761,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
<Text id={autoUnzipLabelId} fw={500} size="sm">
|
||||
{t("settings.general.autoUnzip", "Auto-unzip API responses")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
@@ -760,6 +772,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
aria-labelledby={autoUnzipLabelId}
|
||||
checked={preferences.autoUnzip}
|
||||
onChange={(event) =>
|
||||
updatePreference("autoUnzip", event.currentTarget.checked)
|
||||
@@ -786,7 +799,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} size="sm">
|
||||
<Text id={autoUnzipLimitLabelId} fw={500} size="sm">
|
||||
{t(
|
||||
"settings.general.autoUnzipFileLimit",
|
||||
"Auto-unzip file limit",
|
||||
@@ -800,6 +813,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
aria-labelledby={autoUnzipLimitLabelId}
|
||||
value={fileLimitInput}
|
||||
onChange={setFileLimitInput}
|
||||
onBlur={() => {
|
||||
|
||||
@@ -13,7 +13,7 @@ const Overview: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Stack gap="xs" mb="md">
|
||||
<Text fw={600} size="md" c="blue">
|
||||
<Text fw={600} size="md" c="var(--c-accent-text)">
|
||||
{title}
|
||||
</Text>
|
||||
<Stack gap="xs" pl="md">
|
||||
|
||||
@@ -270,7 +270,7 @@ export default function ProviderCard({
|
||||
href={provider.documentationUrl}
|
||||
target="_blank"
|
||||
size="xs"
|
||||
c="blue"
|
||||
c="var(--c-accent-text)"
|
||||
>
|
||||
{t(
|
||||
"admin.settings.connections.documentation",
|
||||
|
||||
@@ -100,7 +100,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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -126,6 +126,6 @@
|
||||
/* Preview disclaimer */
|
||||
.previewDisclaimer {
|
||||
margin-top: 8px;
|
||||
opacity: 0.7;
|
||||
color: var(--c-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
+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}}",
|
||||
|
||||
@@ -338,7 +338,9 @@ export default function SignControlsPanel({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Group gap={4} wrap="nowrap" c="var(--mantine-color-blue-6)">
|
||||
// Sits on the white signature sheet in both schemes, so it takes a fixed
|
||||
// accent ink rather than the scheme-dependent one.
|
||||
<Group gap={4} wrap="nowrap" c="var(--c-accent-on-light)">
|
||||
<DrawIcon sx={{ fontSize: "0.95rem" }} />
|
||||
<Text size="xs" fw={600}>
|
||||
{t("certSign.collab.signRequest.preview.create", "Add signature")}
|
||||
|
||||
@@ -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
|
||||
@@ -214,7 +215,9 @@ const CompressSettings = ({
|
||||
<Stack
|
||||
gap="xs"
|
||||
style={{
|
||||
opacity: disabled || imageMagickAvailable === false ? 0.6 : 1,
|
||||
// Dimmed enough to read as inactive, but not so far that the
|
||||
// muted labels inside drop below the 4.5:1 text floor.
|
||||
opacity: disabled || imageMagickAvailable === false ? 0.8 : 1,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -244,6 +247,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;
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
|
||||
<Text
|
||||
size="xs"
|
||||
style={{
|
||||
color: "var(--c-primary)",
|
||||
color: "var(--c-accent-text)",
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
textAlign: "center",
|
||||
|
||||
@@ -167,7 +167,7 @@ const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => {
|
||||
{/* Warnings */}
|
||||
{analysis.warnings.length > 0 && (
|
||||
<Box>
|
||||
<Text size="xs" c="orange" fw={500}>
|
||||
<Text size="xs" c="var(--color-amber-dark)" fw={500}>
|
||||
{t("pdfTextEditor.fontAnalysis.warnings", "Warnings")}:
|
||||
</Text>
|
||||
<List size="xs" spacing={2} withPadding>
|
||||
@@ -183,7 +183,7 @@ const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => {
|
||||
{/* Suggestions */}
|
||||
{analysis.suggestions.length > 0 && (
|
||||
<Box>
|
||||
<Text size="xs" c="blue" fw={500}>
|
||||
<Text size="xs" c="var(--c-accent-text)" fw={500}>
|
||||
{t("pdfTextEditor.fontAnalysis.suggestions", "Notes")}:
|
||||
</Text>
|
||||
<List size="xs" spacing={2} withPadding>
|
||||
|
||||
@@ -1691,7 +1691,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
|
||||
>
|
||||
<Stack align="center" gap="md" style={{ pointerEvents: "none" }}>
|
||||
<UploadFileIcon
|
||||
sx={{ fontSize: 48, color: "var(--mantine-color-blue-5)" }}
|
||||
sx={{ fontSize: 48, color: "var(--c-accent-text)" }}
|
||||
/>
|
||||
<Text size="lg" fw={600}>
|
||||
{t("pdfTextEditor.empty.title", "No document loaded")}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
@@ -25,6 +26,11 @@ const RemoveBlanksSettings = ({
|
||||
disabled = false,
|
||||
}: RemoveBlanksSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const whitePercentLabelId = useId();
|
||||
const whitePercentLabel = t(
|
||||
"removeBlanks.whitePercent.label",
|
||||
"White Percent",
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg" mt="md">
|
||||
@@ -46,11 +52,12 @@ const RemoveBlanksSettings = ({
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("removeBlanks.whitePercent.label", "White Percent")}
|
||||
<Text id={whitePercentLabelId} size="sm" fw={500}>
|
||||
{whitePercentLabel}
|
||||
</Text>
|
||||
<Group align="center">
|
||||
<NumberInput
|
||||
aria-labelledby={whitePercentLabelId}
|
||||
value={parameters.whitePercent}
|
||||
onChange={(v) =>
|
||||
onParameterChange("whitePercent", typeof v === "number" ? v : 0.1)
|
||||
@@ -71,6 +78,8 @@ const RemoveBlanksSettings = ({
|
||||
step={0.1}
|
||||
style={{ flex: 1 }}
|
||||
disabled={disabled}
|
||||
// The thumb is a div, so the heading above cannot name it.
|
||||
thumbLabel={whitePercentLabel}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { Stack, Text, Select, ColorInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ReplaceColorParameters } from "@app/hooks/tools/replaceColor/useReplaceColorParameters";
|
||||
@@ -18,6 +19,10 @@ const ReplaceColorSettings = ({
|
||||
disabled = false,
|
||||
}: ReplaceColorSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const operationLabelId = useId();
|
||||
const highContrastLabelId = useId();
|
||||
const textColorLabelId = useId();
|
||||
const backgroundColorLabelId = useId();
|
||||
|
||||
const replaceAndInvertOptions = [
|
||||
{
|
||||
@@ -60,10 +65,11 @@ const ReplaceColorSettings = ({
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
<Text id={operationLabelId} size="sm" fw={500}>
|
||||
{t("replaceColor.labels.colourOperation", "Colour operation")}
|
||||
</Text>
|
||||
<Select
|
||||
aria-labelledby={operationLabelId}
|
||||
value={parameters.replaceAndInvertOption}
|
||||
onChange={(value) =>
|
||||
value &&
|
||||
@@ -83,10 +89,11 @@ const ReplaceColorSettings = ({
|
||||
|
||||
{parameters.replaceAndInvertOption === "HIGH_CONTRAST_COLOR" && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
<Text id={highContrastLabelId} size="sm" fw={500}>
|
||||
{t("replace-color.selectText.5", "High contrast color options")}
|
||||
</Text>
|
||||
<Select
|
||||
aria-labelledby={highContrastLabelId}
|
||||
value={parameters.highContrastColorCombination}
|
||||
onChange={(value) =>
|
||||
value &&
|
||||
@@ -108,10 +115,11 @@ const ReplaceColorSettings = ({
|
||||
{parameters.replaceAndInvertOption === "CUSTOM_COLOR" && (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
<Text id={textColorLabelId} size="sm" fw={500}>
|
||||
{t("replace-color.selectText.10", "Choose text Color")}
|
||||
</Text>
|
||||
<ColorInput
|
||||
aria-labelledby={textColorLabelId}
|
||||
value={parameters.textColor}
|
||||
onChange={(value) => onParameterChange("textColor", value)}
|
||||
format="hex"
|
||||
@@ -124,10 +132,11 @@ const ReplaceColorSettings = ({
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
<Text id={backgroundColorLabelId} size="sm" fw={500}>
|
||||
{t("replace-color.selectText.11", "Choose background Color")}
|
||||
</Text>
|
||||
<ColorInput
|
||||
aria-labelledby={backgroundColorLabelId}
|
||||
value={parameters.backGroundColor}
|
||||
onChange={(value) => onParameterChange("backGroundColor", value)}
|
||||
format="hex"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useId, useState, useEffect } from "react";
|
||||
import { Stack, Text, NumberInput } from "@mantine/core";
|
||||
|
||||
interface NumberInputWithUnitProps {
|
||||
@@ -20,6 +20,7 @@ const NumberInputWithUnit = ({
|
||||
max,
|
||||
disabled = false,
|
||||
}: NumberInputWithUnitProps) => {
|
||||
const labelId = useId();
|
||||
const [localValue, setLocalValue] = useState<number | string>(value);
|
||||
|
||||
// Sync local value when external value changes
|
||||
@@ -34,6 +35,7 @@ const NumberInputWithUnit = ({
|
||||
return (
|
||||
<Stack gap="xs" style={{ flex: 1 }}>
|
||||
<Text
|
||||
id={labelId}
|
||||
size="xs"
|
||||
fw={500}
|
||||
style={{
|
||||
@@ -51,6 +53,7 @@ const NumberInputWithUnit = ({
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
aria-labelledby={labelId}
|
||||
rightSection={
|
||||
<Text size="sm" c="dimmed" pr="sm">
|
||||
{unit}
|
||||
|
||||
@@ -109,9 +109,8 @@ const ToolStep = ({
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
opacity: isCollapsed ? 0.8 : 1,
|
||||
color: isCollapsed ? "var(--mantine-color-dimmed)" : "inherit",
|
||||
transition: "opacity 0.2s ease, color 0.2s ease",
|
||||
transition: "color 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{/* Chevron icon to collapse/expand the step */}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
.showjs-outline-button {
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
color: var(--mantine-color-blue-5);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.showjs-scrollarea {
|
||||
|
||||
@@ -29,7 +29,11 @@ const FavoriteStar: React.FC<FavoriteStarProps> = ({
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
// A span, not a button: this renders inside the tool row's own button in
|
||||
// the fullscreen tool lists, and a control may not nest inside a control.
|
||||
// role="button" is still required for the aria-label to be permitted.
|
||||
as="span"
|
||||
role="button"
|
||||
variant="tertiary"
|
||||
shape="circle"
|
||||
size={SIZE_MAP[size]}
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ const SignatureSection = ({
|
||||
<SignatureStatusBadge signature={signature} />
|
||||
</Group>
|
||||
{signature.errorMessage && (
|
||||
<Text c="red" size="sm">
|
||||
<Text c="var(--color-red-dark)" size="sm">
|
||||
{signature.errorMessage}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
+24
-19
@@ -18,35 +18,40 @@ const SignatureStatusBadge = ({
|
||||
neutral: "status-badge status-badge--neutral",
|
||||
} as const;
|
||||
|
||||
// With no details there is nothing to open, so the badge stays a plain label.
|
||||
// Popover.Target stamps aria-haspopup/aria-expanded onto whatever it wraps,
|
||||
// and those are only permitted on an element that is actually a control.
|
||||
if (status.details.length === 0) {
|
||||
return (
|
||||
<Badge className={classMap[status.kind]} variant="light">
|
||||
{status.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
withinPortal
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
disabled={status.details.length === 0}
|
||||
>
|
||||
<Popover withinPortal position="bottom" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<Badge
|
||||
component="button"
|
||||
type="button"
|
||||
className={classMap[status.kind]}
|
||||
variant="light"
|
||||
style={{ cursor: status.details.length ? "pointer" : "default" }}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</Popover.Target>
|
||||
{status.details.length > 0 && (
|
||||
<Popover.Dropdown>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t("details", "Details")}
|
||||
<Popover.Dropdown>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
{t("details", "Details")}
|
||||
</Text>
|
||||
{status.details.map((d, i) => (
|
||||
<Text size="sm" key={i}>
|
||||
- {d}
|
||||
</Text>
|
||||
{status.details.map((d, i) => (
|
||||
<Text size="sm" key={i}>
|
||||
- {d}
|
||||
</Text>
|
||||
))}
|
||||
</Popover.Dropdown>
|
||||
)}
|
||||
))}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
}
|
||||
|
||||
.attachment-item:hover .attachment-item__download-icon {
|
||||
color: var(--mantine-color-blue-6);
|
||||
color: var(--c-accent-text);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
|
||||
@@ -400,7 +400,7 @@ export const AttachmentSidebar = ({
|
||||
|
||||
{attachmentSupport && documentCacheKey && currentError && (
|
||||
<Stack gap="xs" align="center" className="sidebar-base__error">
|
||||
<Text size="sm" c="red" ta="center">
|
||||
<Text size="sm" c="var(--color-red-dark)" ta="center">
|
||||
{currentError}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
|
||||
@@ -720,7 +720,7 @@ export const BookmarkSidebar = ({
|
||||
|
||||
{bookmarkSupport && documentCacheKey && currentError && (
|
||||
<Stack gap="xs" align="center" className="sidebar-base__error">
|
||||
<Text size="sm" c="red" ta="center">
|
||||
<Text size="sm" c="var(--color-red-dark)" ta="center">
|
||||
{currentError}
|
||||
</Text>
|
||||
<Button variant="secondary" size="sm" onClick={requestReload}>
|
||||
@@ -804,7 +804,7 @@ export const BookmarkSidebar = ({
|
||||
disabled={isSavingBookmark}
|
||||
/>
|
||||
{addBookmarkError && (
|
||||
<Text size="xs" c="red">
|
||||
<Text size="xs" c="var(--color-red-dark)">
|
||||
{addBookmarkError}
|
||||
</Text>
|
||||
)}
|
||||
@@ -884,7 +884,7 @@ export const BookmarkSidebar = ({
|
||||
icon="bookmark-add-rounded"
|
||||
width="0.95rem"
|
||||
height="0.95rem"
|
||||
style={{ color: "var(--mantine-color-blue-5)" }}
|
||||
style={{ color: "var(--c-accent-text)" }}
|
||||
/>
|
||||
<Text
|
||||
size="xs"
|
||||
|
||||
@@ -287,7 +287,7 @@ function AnnotationTypeIcon({ ann }: { ann: PdfAnnotationObject }) {
|
||||
icon={iconName}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ flexShrink: 0, color: "var(--mantine-color-blue-5)" }}
|
||||
style={{ flexShrink: 0, color: "var(--c-accent-text)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1155,7 +1155,10 @@ export function CommentsSidebar({
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="blue">
|
||||
<Text
|
||||
size="xs"
|
||||
c="var(--c-accent-text)"
|
||||
>
|
||||
{t(
|
||||
"annotation.editText",
|
||||
"Edit",
|
||||
|
||||
@@ -1174,7 +1174,7 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
{!effectiveFile ? (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Text c="red">
|
||||
<Text c="var(--color-red-dark)">
|
||||
{t(
|
||||
"viewer.error.noFileProvided",
|
||||
"Error: No file provided to viewer",
|
||||
|
||||
@@ -373,7 +373,7 @@ export function LayerSidebar({
|
||||
|
||||
{status === "error" && (
|
||||
<div className="sidebar-base__error">
|
||||
<Text size="sm" c="red" ta="center">
|
||||
<Text size="sm" c="var(--color-red-dark)" ta="center">
|
||||
{loadError ?? "Failed to load layers."}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -486,7 +486,11 @@ export function LocalEmbedPDF({
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<div style={{ fontSize: "24px" }}>❌</div>
|
||||
<Text c="red" size="sm" style={{ textAlign: "center" }}>
|
||||
<Text
|
||||
c="var(--color-red-dark)"
|
||||
size="sm"
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
Error loading PDF engine: {error.message}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mantine-color-blue-6);
|
||||
color: var(--c-accent-text);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export function JsonViewer({ file }: JsonViewerProps) {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="red">
|
||||
<Text size="xs" c="var(--color-red-dark)">
|
||||
{t("viewer.nonPdf.invalidJson")}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
@@ -93,6 +93,10 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
||||
margin: "0 auto",
|
||||
padding: "20px 28px",
|
||||
background: "#ffffff",
|
||||
// The rendered page is a white sheet in either scheme, so its
|
||||
// copy takes a fixed dark ink rather than inheriting the theme's,
|
||||
// which would be near-white here.
|
||||
color: "var(--c-text-on-light)",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
@@ -122,7 +126,7 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
|
||||
paddingRight: 16,
|
||||
paddingLeft: 4,
|
||||
textAlign: "right",
|
||||
color: "var(--mantine-color-gray-5)",
|
||||
color: "var(--c-text-muted)",
|
||||
userSelect: "none",
|
||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||
minWidth: `${String(lines.length).length + 1}ch`,
|
||||
|
||||
@@ -1107,7 +1107,7 @@ export default function MobileScannerPage() {
|
||||
<PhotoCameraRoundedIcon
|
||||
style={{
|
||||
fontSize: "3rem",
|
||||
color: "var(--mantine-color-blue-6)",
|
||||
color: "var(--c-accent-text)",
|
||||
}}
|
||||
/>
|
||||
<Text size="lg" fw={600}>
|
||||
|
||||
@@ -62,7 +62,7 @@ code {
|
||||
}
|
||||
|
||||
.stirling-link {
|
||||
color: var(--mantine-color-blue-6);
|
||||
color: var(--c-accent-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
--gray-100: 243 244 246;
|
||||
--gray-200: 229 231 235;
|
||||
--gray-300: 209 213 219;
|
||||
/* Channel form of gray-400. Only for decorative fills/borders — as text it
|
||||
reaches 2.3:1, so label copy uses --gray-600. */
|
||||
--gray-400: 156 163 175;
|
||||
--gray-500: 107 114 128;
|
||||
--gray-600: 75 85 99;
|
||||
@@ -86,9 +88,9 @@
|
||||
--color-primary-900: var(--p-blue-700);
|
||||
|
||||
/* Success (green) */
|
||||
--color-green-50: var(--p-green-500);
|
||||
--color-green-100: var(--p-green-500);
|
||||
--color-green-200: var(--p-green-500);
|
||||
--color-green-50: var(--p-green-50);
|
||||
--color-green-100: var(--p-green-100);
|
||||
--color-green-200: var(--p-green-200);
|
||||
--color-green-300: var(--p-green-500);
|
||||
--color-green-400: var(--p-green-500);
|
||||
--color-green-500: var(--p-green-500);
|
||||
@@ -98,9 +100,9 @@
|
||||
--color-green-900: var(--p-green-700);
|
||||
|
||||
/* Warning (yellow) */
|
||||
--color-yellow-50: var(--p-amber-400);
|
||||
--color-yellow-100: var(--p-amber-400);
|
||||
--color-yellow-200: var(--p-amber-400);
|
||||
--color-yellow-50: var(--p-amber-50);
|
||||
--color-yellow-100: var(--p-amber-100);
|
||||
--color-yellow-200: var(--p-amber-200);
|
||||
--color-yellow-300: var(--p-amber-400);
|
||||
--color-yellow-400: var(--p-amber-400);
|
||||
|
||||
@@ -131,9 +133,9 @@
|
||||
--color-yellow-800: var(--p-amber-600);
|
||||
--color-yellow-900: var(--p-amber-600);
|
||||
|
||||
--color-red-50: var(--p-red-400);
|
||||
--color-red-100: var(--p-red-400);
|
||||
--color-red-200: var(--p-red-400);
|
||||
--color-red-50: var(--p-red-50);
|
||||
--color-red-100: var(--p-red-100);
|
||||
--color-red-200: var(--p-red-200);
|
||||
--color-red-300: var(--p-red-400);
|
||||
--color-red-400: var(--p-red-400);
|
||||
--color-red-500: var(--p-red-500);
|
||||
@@ -409,10 +411,10 @@
|
||||
--special-color-recommended: var(--p-blue-500); /* Cyan for recommended */
|
||||
|
||||
/* Success (green) - dark */
|
||||
--color-green-50: var(--p-green-700);
|
||||
--color-green-100: var(--p-green-700);
|
||||
--color-green-200: var(--p-green-700);
|
||||
--color-green-300: var(--p-green-700);
|
||||
--color-green-50: var(--p-zinc-775);
|
||||
--color-green-100: var(--p-zinc-700);
|
||||
--color-green-200: var(--p-zinc-600);
|
||||
--color-green-300: var(--p-zinc-500);
|
||||
--color-green-400: var(--p-green-600);
|
||||
--color-green-500: var(--p-green-500);
|
||||
--color-green-600: var(--p-green-600);
|
||||
@@ -421,10 +423,10 @@
|
||||
--color-green-900: var(--p-green-500);
|
||||
|
||||
/* Warning (yellow) - dark */
|
||||
--color-yellow-50: var(--p-amber-600);
|
||||
--color-yellow-100: var(--p-amber-600);
|
||||
--color-yellow-200: var(--p-amber-600);
|
||||
--color-yellow-300: var(--p-amber-600);
|
||||
--color-yellow-50: var(--p-zinc-775);
|
||||
--color-yellow-100: var(--p-zinc-700);
|
||||
--color-yellow-200: var(--p-zinc-600);
|
||||
--color-yellow-300: var(--p-zinc-500);
|
||||
--color-yellow-400: var(--p-amber-600);
|
||||
--color-yellow-500: var(--p-amber-500);
|
||||
--color-yellow-600: var(--p-amber-400);
|
||||
@@ -570,13 +572,6 @@
|
||||
}
|
||||
|
||||
/* Plan section card borders - only override in dark mode */
|
||||
[data-mantine-color-scheme="dark"] .plan-card {
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .plan-card [data-size="sm"] {
|
||||
color: var(--p-c-c2c8e0) !important;
|
||||
}
|
||||
|
||||
/* Current plan badge - use light mode green in dark mode */
|
||||
[data-mantine-color-scheme="dark"] .current-plan-badge {
|
||||
background-color: var(--color-green-300) !important;
|
||||
@@ -584,12 +579,15 @@
|
||||
|
||||
/* Plan section button colors */
|
||||
.plan-button:not(:disabled):not([data-disabled]) {
|
||||
background-color: var(--p-azure-500) !important;
|
||||
background-color: var(--p-azure-700) !important;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.plan-button:not(:disabled):not([data-disabled]) {
|
||||
background-color: var(--p-royal-700) !important;
|
||||
/* The fill is set here, so the label has to be set with it — left alone it
|
||||
keeps the scheme's default ink and reads as dark-on-deep-blue. */
|
||||
color: var(--p-white) !important;
|
||||
}
|
||||
|
||||
/* Lighter grey for disabled plan buttons */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user