Compare commits

...
Author SHA1 Message Date
Connor Yoh 6e5267162c Hide Infrastructure tab in portal SaaS build
The Infrastructure view isn't wired up for SaaS yet, so hide it from the
sidebar there until it is. Adds a HIDDEN_NAV_VIEWS flavor seam: core is
empty (self-hosted shows the full nav) and the portal-saas shadow hides
"infrastructure". The Sidebar filters nav entries against it.

Bring it back by emptying the set in portal-saas/components/navVisibility.
2026-07-08 14:49:45 +01:00
Anthony Stirling 0692058602 Embed admin portal as its own app in the jar behind buildWithPortal (#6911)
## What

Lets the admin portal ("Stirling Processor") ship **inside the JAR**,
gated by a build flag. On `main` the portal already exists as a lazy
`/portal/*` route in the editor but isn't included in production builds
and isn't reachable in a login-enabled server. This PR makes it a
**flag-gated, directly-navigable** part of the editor bundle, and wires
it into the PR preview deployment so it can be tried live.

It keeps the exact architecture `main` uses (portal = a lazy chunk of
the editor, not a separate app), so it inherits all the editor's global
providers/styles and there's no second build to maintain.

## How

**Frontend - gate the existing lazy route**
([`adminRouteExtensions.tsx`](frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx))
```ts
const includePortal = import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
const PortalApp = includePortal ? lazy(() => import("@portal/PortalApp")) : null;
```
Vite bakes the env to a literal, so when off the dynamic import is
**tree-shaken out entirely** (no `PortalApp` chunk emitted). Always on
in dev. `VITE_INCLUDE_PORTAL` is typed in `vite-env.d.ts` and declared
(default `false`) in `editor/.env`.

**Gradle** ([`build.gradle`](app/core/build.gradle)) -
`-PbuildWithPortal=true` forces `buildWithFrontend=true` and sets
`VITE_INCLUDE_PORTAL=true` on the editor build. Process-env takes
priority over `.env`, so the flag wins for JAR builds while plain `vite
build` / Cloudflare Pages default to off.

**Backend - make the shell reachable**
([`RequestUriUtils`](app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java))
- permits `/portal` + `/portal/*` as public SPA routes. The editor keeps
its JWT in localStorage (not a cookie), so a direct nav/refresh to
`/portal` isn't authenticated at the server and would otherwise redirect
to `/login` and never load. Serving the shell pre-auth (like the editor
root already is) lets it load; **access control is unchanged** - the
portal has its own auth gate + `RequirePortalAccess`, and its data APIs
stay protected.

**Docker** - the embedded Dockerfiles take `ARG BUILD_PORTAL=false` →
`-PbuildWithPortal=${BUILD_PORTAL}`. Default off, so official
`push-docker` images do **not** bundle the portal.

**CI - scoped to the PR preview deploy only**
([`PR-Auto-Deploy-V2.yml`](.github/workflows/PR-Auto-Deploy-V2.yml)) -
the one job that builds the JAR and comments owns all portal wiring:
passes `BUILD_PORTAL=true`, enables the portal's backend features
(`POLICIES_ENABLED`, `STIRLING_BILLING_ACCOUNT_LINK_ENABLED`), and adds
an "Admin portal included" line (linking `/portal` via the direct IP) to
the deployment comment. `push-docker`, `build.yml`, `test-build-docker`,
and the shared paths-filter are untouched.

## Validation (real, in the JAR)

Built and booted the JAR with `-PbuildWithPortal=true` and login
enabled:
- `/portal` and `/portal/users` load via direct nav and render **fully
themed** (dark surfaces, gradients, filled buttons).
- Editor-only build (`-PbuildWithFrontend=true`, no portal flag) →
editor ships, **0 portal chunks** (tree-shaken).
- `-PbuildWithPortal=true` → `PortalApp` chunk present.

Green: `frontend:check:all` (typecheck all variants, lint, format,
build, tests incl. the `VITE_*` env guard), backend compile,
`RequestUriUtilsTest`, spotless.

## Notes

- **Official images never bundle the portal** (Dockerfile default off);
only the PR preview does. Flip `BUILD_PORTAL` / `-PbuildWithPortal` to
include it elsewhere.
- The `/portal` shell being public is the one deviation from `main`, and
it's required for the route to be reachable at all in a login-enabled
server; data access is still fully gated.
2026-07-08 12:55:22 +00:00
James Brunton 8150d16b6f Support bidirectional mapping for Change Metadata (#6906)
# Description of Changes
The Change Metadata tool was missed from the bidirectional mappings
added in #6867. This PR adds it to the list of supported tools.
2026-07-08 12:47:11 +00:00
Reece Browne c8f238ae60 Portal/editor switcher (#6907)
Adds the portal's top-left app switcher to the editor sidebar, so you
can jump between the two apps from either side.

- Both sidebars render the same shared `AppSwitch` component (sui
dropdown).
- Switching is client-side (no page reload); portal→editor no longer
breaks when `VITE_EDITOR_URL` is unset.
- Editor side is admin-gated (`portalAccess`) and only exists in flavors
that ship the portal — core/desktop stub it out, same seam pattern as
the portal routes.
- Fixes en route: dropdown menu stacking in the editor sidebar, sui
dropdown item button reset, stale `dist-portal` ESLint ignore.
2026-07-08 12:17:00 +00:00
ConnorYoh 8df49ac053 feat(portal): build portal for SaaS and self-hosted via file-override layer (#6900)
## What

Ground-work so the admin portal can build for the **SaaS flavor**
alongside self-hosted, using the editor's existing **build-time
file-override** mechanism — no runtime flavor flags. This PR
demonstrates SaaS end-to-end (single-login + Usage page loading via the
inherited Supabase session) without changing self-hosted behaviour.

This is intentionally scoped as foundations, not the whole feature.

## How

**Build hook**
- `tsconfig.saas.vite.json`: `@portal/*` now cascades
(`src/saas/portal/*` → `src/portal/*`); added `@portalCore/*` for the
explicit base path.
- New `src/portal-saas/` layer (sibling of `src/portal`) holds SaaS-only
overrides, so `@app/*` resolves only editor layers and `@portal/*` only
portal layers. Self-hosted builds never import it (tree-shaken).

**Seams live in the api-client + composition layers — never in page
components**
- `saasApiBase` — base URL source (self-hosted: `VITE_SAAS_API_URL`;
SaaS reuses the single `VITE_API_BASE_URL` backend).
- `portalSaasSession` — flavor-agnostic token from the shared Supabase
client.
- `PortalAuthBoundary` — self-hosted: Spring `AuthProvider` +
`AuthGate`; SaaS: Supabase `AuthProvider` + session-only gate (inherits
the SaaS session, so no second login).

**Link concept pulled out of the Usage page (one clean cut)**
- `Usage` is now a link-free wallet renderer with generic
`onWalletLoaded` / `onReauth` callbacks; it always loads the wallet and
has zero flavor awareness.
- `PortalBillingGate` is the single flavor seam: self-hosted gates on
link (prompt when unlinked; wires the callbacks onto link/tier +
re-auth), SaaS is a passthrough that renders `Usage` directly.
- Keeps the flavor switch out of the page entirely (no per-flavor code
in `Usage`).

## Testing
Green locally and in CI (CI runs the umbrella `task
frontend:check:all`):
- `task frontend:typecheck:all` — clean across all 7 build variants
- `task frontend:test` — vitest suites pass (portal + saas cover this
change; 146 tests)
- `task frontend:build:saas` and `task frontend:build:proprietary` —
both green
- `task frontend:lint` and `task frontend:format:check` — clean

## Also in this PR (added after the initial foundations)
- **Tier from wallet + full link-layer excision on SaaS.** `TierContext`
no longer reads `LinkContext` (via a `usePlanTier` seam: self-hosted
from link state, SaaS from `wallet.status`), and the SaaS
`PortalProviders` drops `LinkProvider` / `AccountLinkProvider` /
`LinkModalHost` entirely — the link machinery is *absent* from the SaaS
bundle, not mounted-but-inert.

## Deliberately out of scope (follow-ups)
- SaaS-only read-only "connected servers" settings view.
- Shared wallet source so the SaaS tier badge and the Usage page don't
both fetch `/payg/wallet` (harmless double-fetch today).
2026-07-08 12:07:54 +00:00
Anthony Stirling 328cd8c664 Claude skills walkthrough, feature-walkthrough, and before/after (#6862)
# Description of Changes

Add review only Claude skills

### Skills (Using
https://github.com/Stirling-Tools/Stirling-PDF/pull/6655 as example for
example files)
- **`/ui-walkthrough`** - captures every state of a feature's UI
(empty/populated/dialogs, light + dark + RTL) via the stubbed Playwright
harness, builds a single-image HTML report with a global light/dark
slider, then runs visual-consistency + UX review passes. `--fix`
auto-applies safe fixes and re-shoots.

[REAL-ui-walkthrough-pr6655.html](https://github.com/user-attachments/files/29594945/REAL-ui-walkthrough-pr6655.html)

- **`/feature-walkthrough`** — explains a branch end-to-end (Mermaid
diagrams, annotated file map, before/after, "try it locally") so a
reviewer with no prior context can follow it.

[REAL-feature-walkthrough-pr6655.html](https://github.com/user-attachments/files/29594950/REAL-feature-walkthrough-pr6655.html)

- **`/ui-before-after`** — generic branch/PR visual diff: derives the
changed UI from the diff, screenshots before (base) vs after (head),
pixel-diffs, auto-crops each pair to the region that actually changed
(full-page only when the change is page-wide), and builds PR-ready
before/after montages.

[REAL-ui-before-after-pr6655.html](https://github.com/user-attachments/files/29594954/REAL-ui-before-after-pr6655.html)




---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-08 09:46:55 +00:00
Anthony Stirling f703a67817 Fix cert sign not showing under certain instances (#6908) 2026-07-07 22:39:57 +01:00
stirlingbot[bot]andLudy 105af51100 Update Frontend 3rd Party Licenses (#6889)
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
2026-07-07 22:08:01 +01:00
57bf17d348 Fix missing app icon on Linux/Wayland (#6875)
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-07-07 21:58:21 +01:00
Ludy 11df30b914 feat(ui): add dedicated third-party license sections to settings (#6820) 2026-07-07 21:57:34 +01:00
Ludy 43162c40ad chore(frontend): remove unused OG images (#6826) 2026-07-07 21:56:16 +01:00
James Brunton be57f11747 Improve type safety of tool definitions (#6895)
# Description of Changes
Followup work requested in review of #6867. Currently, there is nothing
enforcing that the endpoint chosen in the tool config is the correct
mapping for `toApiParams`, so theoretically it's possible for a tool to
be set up to call an endpoint with the wrong API params for it. There's
also nothing currently enforcing that `toApiParams` and `fromApiParams`
are compatible with each other (using the same types). This PR changes
it so that instead of creating the config object directly, tools create
it via a generic function, which enforces that all of the relevant
mappings are using compatible types.
2026-07-07 16:43:06 +00:00
EthanHealy01 8ba8f69252 Consolidate buttons and related components (#6787)
SegmentedControl, Chip, ChipFlow. Bring in the portal dark mode theme
and other small fixes to issues I found during testing
2026-07-07 16:06:56 +00:00
Reece Browne be97268a7c SUI - setting up mantine backed SUI components (#6890)
## Summary

Converts SUI's existing Select and Slider to Mantine-backed
implementations, and adds three new Mantine-backed SUI components:
MultiSelect, NumberInput, ColorInput.

All five components follow the same contract as the rest of the SUI
catalogue:
- Imported from `@app/ui` — Mantine is an implementation detail
- Explicit prop allowlists: appearance props (color, variant, radius,
classNames, styles) are locked internally to SUI tokens; only
behavioural props are exposed
- Labels and error messages stripped from the interface — callers use
`<FormField>` for both. The components take an `invalid` flag that
applies error styling only; Mantine never renders its own message
element, so the text can't appear twice
- `aria-label` / `aria-invalid` / `aria-describedby` and `FormField`'s
injected `required` are forwarded, so the injected accessibility wiring
reaches the underlying input. Mantine drops some of this wiring
internally (`aria-describedby` on inputs, all aria props on Slider's
thumb, `required` on MultiSelect's field), so `ariaForwarding.ts`
re-applies it to the DOM node and `ariaForwarding.test.tsx` locks the
contract in
- Typed escape hatches (`comboboxProps`, `popoverProps`, `rightSection`)
documented for the z-index-in-modal use case

**Select** — rebuilt from native `<select>` to Mantine combobox. Gains
searchable/clearable. `onChange` now receives the value string directly,
not a DOM event — callers updated.

**Slider** — rebuilt from native `<input type="range">` to Mantine
Slider. Gains accessible keyboard navigation and `marks` support.

**MultiSelect, NumberInput, ColorInput** — new components. The behaviour
(multi-select combobox, number stepper, colour picker) is too complex to
hand-build correctly; Mantine provides it for free behind a locked SUI
interface.

Also wires `suiCssVariablesResolver` into the Storybook
`MantineProvider` so Mantine combobox/popover dropdowns follow the SUI
palette in dark mode, and adds `"neutral"` accent variant to
`IconBadge`.

## Usage

```tsx
import { Select, Slider, MultiSelect, NumberInput, ColorInput } from "@app/ui";
import { FormField } from "@app/ui/FormField";

// Select — onChange receives string | null, not a DOM event
<FormField label="Retention">
  <Select options={options} value={value} onChange={setValue} searchable clearable />
</FormField>

// Slider — same external API as before, now with marks support
<FormField label="Confidence">
  <Slider value={v} onChange={setV} min={0} max={1} marks={[{ value: 0.5, label: "0.5" }]} />
</FormField>

// New components
<FormField label="PII types">
  <MultiSelect data={options} value={value} onChange={setValue} searchable clearable />
</FormField>

<FormField label="Opacity">
  <NumberInput value={opacity} onChange={setOpacity} min={0} max={100} suffix="%" />
</FormField>

<FormField label="Watermark colour">
  <ColorInput value={color} onChange={setColor} />
</FormField>
```

## Notes

- **Select `onChange` is a breaking change** — receives `string | null`
instead of a DOM event. All existing callers in this repo are updated.
- The policy PR (`main` WIP) depends on this merging first.
- Stories for all five components are under **Primitives / Forms** in
Storybook.
2026-07-07 14:29:55 +00:00
736 changed files with 11796 additions and 6059 deletions
@@ -0,0 +1,97 @@
---
name: feature-walkthrough
description: >-
Explain the full logic and process of the current branch end-to-end so someone
with no prior knowledge of the task can understand, review, and reproduce it.
Scopes the change from the branch diff, traces the flow across every layer it
touches (frontend tool/hook/component, Java controller/service/endpoint, Python
engine, config, i18n, tests), and produces a self-contained walkthrough document
with Mermaid diagrams (sequence/flow/architecture), annotated file map with
clickable references, before/after behavior, screenshots where a UI is involved,
a "try it locally" section, and edge cases/risks. Use when asked for a feature or
branch walkthrough, "explain what this branch does", a design/logic writeup, PR
reviewer onboarding, or a hand-off doc. Pass --html to also emit a rendered HTML
version; --no-screens to skip screenshots.
argument-hint: "[branch-or-area] [--html] [--no-screens]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# Feature / Branch Walkthrough
Turn the current branch into a walkthrough a newcomer can follow. Audience:
**someone who has never seen this task**. Explain the *why*, the *flow*, and *how to
try it* - not just a diff summary.
`$ARGUMENTS` may name a branch or area to focus on; default is the current branch
vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`.
## Process
### 1. Scope the change
- `git log --oneline main..HEAD` and `git diff --stat main...HEAD` for the shape.
- Read the PR description / commit messages for stated intent. Do **not** invent
history or motivation that isn't evidenced (state current behavior in present tense).
- Classify touched files by layer:
- **Frontend**: tools (`frontend/editor/src/core/components/tools/*` or `.../core/tools/*`),
hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n
(`public/locales/en-US`).
- **Java backend**: controllers (`.../controller/api/...`), services, models, config.
- **Engine**: `engine/src/stirling/{agents,contracts,api,services}`.
- **Config / build / docker / tests.**
### 2. Trace the flow end-to-end
Follow one real path from user action to result. For a typical PDF tool that's:
UI control → `useToolOperation` hook → `POST /api/v1/...` → Spring controller →
service (PDFBox / LibreOffice / engine call) → response → review panel → download.
Read the actual files so the narrative is true to the code, and collect the exact
file:line anchors you'll cite.
### 3. Draw the diagrams (Mermaid)
Pick what fits; usually 2-3 of:
- **Sequence diagram** - request/response across frontend → backend → engine.
- **Flowchart** - the core decision/branching logic of the feature.
- **Architecture/component** - new pieces and how they wire to existing ones.
- **State** - if the feature has modes/steps.
Keep nodes labeled in plain language. Validate the Mermaid parses before shipping.
### 4. Screenshots (unless --no-screens)
If a UI is involved, capture key states with the stubbed Playwright harness
(see the **ui-walkthrough** skill and `files-page-screenshots.spec.ts` for the
pattern) or, for before/after, capture `main` then the branch. Drop PNGs in
`walkthrough/<feature>/` and reference them from the doc. For backend-only
changes, show request/response examples (curl + JSON) instead.
### 5. Write the walkthrough
Create `walkthrough/<feature>/FEATURE-WALKTHROUGH.md` with:
1. **TL;DR** - what the branch does and who it's for, in 3-4 sentences.
2. **Problem & approach** - what wasn't possible before; the chosen solution.
3. **Architecture diagram** + 1-paragraph orientation.
4. **End-to-end flow** - the sequence diagram + a numbered walk of each step,
each citing the real file (clickable `path:line`).
5. **Key files** - annotated map (path → one line on its role).
6. **Logic deep-dive** - the flowchart + prose for the non-obvious decisions.
7. **Behavior** - before vs after; screenshots or request/response examples.
8. **Try it locally** - exact steps (`task dev` / `task dev:all`, the route to
open or the curl to run, any env like `DOCKER_ENABLE_SECURITY` or a test
license key). Make it copy-pasteable.
9. **Edge cases, risks, follow-ups** - what's untested, known limits, gotchas.
Markdown is the primary deliverable - it renders with diagrams in GitHub PRs and
IDEs, no build step, ideal for review.
### 6. If `--html`
Also emit `walkthrough/<feature>/walkthrough.html`: the same content with Mermaid
rendered via `mermaid.initialize({startOnLoad:true})` (script from CDN; note in
the file that rendering diagrams needs network, the `.md` is the offline copy) and
screenshots inline. Keep it self-contained otherwise.
### 7. Deliver
Give the doc path and a short chat summary. Offer to `SendUserFile` it.
## Principles
- **True to the code.** Every claim traces to a file you read; cite `path:line`.
No fabricated migration/version history.
- **Newcomer-first.** Define repo-specific terms (FileContext, `useToolOperation`,
the `@app/*` layer cascade, stubbed vs live tests) on first use.
- **Show, don't assert.** Prefer a diagram + a real example over adjectives.
- Don't commit the `walkthrough/` output unless asked.
+122
View File
@@ -0,0 +1,122 @@
---
name: ui-before-after
description: >-
Analyse a branch or PR and automatically capture before/after screenshots of
every UI surface its changes touch, then pixel-diff the pairs to surface what
actually changed and assemble PR-ready before/after montage images. Generic and
diff-driven: it derives the capture targets from the diff (changed tools/routes →
URLs) instead of hand-listing screens, captures "before" from the base branch and
"after" from the head, then keeps only the views that visually differ. Each
comparison is auto-cropped to the region that actually changed (the bounding box of
differing pixels), falling back to the full page only when the change spans most of
it. Use for before/after shots, a visual diff of a branch/PR, "screenshots for the
PR description", "show what changed in the UI", or a side-by-side of UI changes.
Takes a PR number/URL (resolved via gh) or a branch; defaults to the current branch
vs its base. Flags: --scope <selector>, --base <ref|merge-base>, --theme
light|dark|both, --all (capture every route, not just changed), --no-autocrop,
--pagewide <n>, --threshold <n>.
argument-hint: "[PR# | PR-url | branch] [--scope <sel>] [--base <ref>] [--theme both] [--all] [--no-autocrop]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# UI Before / After (generic visual diff)
Point it at a branch or PR; it figures out which UI changed, screenshots every
affected surface **before** (base) and **after** (head), pixel-diffs the pairs, and
montages the ones that actually changed into images for the PR description.
`$ARGUMENTS`: a PR number/URL, a branch, or nothing (current branch vs base).
By default it captures the full viewport and auto-crops each comparison to the region
that changed. Flags: `--scope <css>` (narrow the *capture* to a container, e.g.
`[data-sidebar="tool-panel"]`, when you already know where the change is),
`--no-autocrop` (keep full frames), `--pagewide <fraction>` (above this share of the
page, skip cropping; default 0.6), `--base <ref|merge-base>`,
`--theme light|dark|both`, `--all` (walk every route, not just changed),
`--threshold <fraction>` (diff sensitivity, default 0.001).
Shares the capture harness with **ui-walkthrough** - read its SKILL.md for the
stubbed-Playwright setup, worktree node_modules + `generate-icons`, the
stale-`:5173` gotcha, and the dark-mode init-script. Bundled helpers:
[capture-spec.template.ts](capture-spec.template.ts), [diff-shots.mjs](diff-shots.mjs),
[montage-template.html](montage-template.html), [shoot-sections.mjs](shoot-sections.mjs).
## Process
### 1. Resolve target + base
```
gh pr view <pr> --json number,title,headRefName,baseRefName,url,files # PR
# or branch: base = merge-base(main, HEAD); head = HEAD
gh pr diff <pr> --name-only # or: git diff --name-only <base>...HEAD
```
### 2. Derive capture targets from the diff (the "analyse" step - no hand-listing)
Map changed frontend files to URLs generically:
- **Tools**: a changed `components/tools/<toolDir>/…` or `hooks/tools/<tool>/…`
toolId → URL via the repo's own rule `getToolUrlPath` in
[toolsTaxonomy.ts:200](frontend/editor/src/core/data/toolsTaxonomy.ts): `/` + the
id kebab-cased (`addPageNumbers``/add-page-numbers`).
- **Pages/routes**: changed `filesPage/*``/files`, etc.
- `--all`: enumerate every tool in the registry instead of just changed ones.
Write `frontend/editor/screenshots/ui-diff/targets.json` =
`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes
it generic - the spec never names a tool.
### 3. Capture AFTER (head) then BEFORE (base)
Copy [capture-spec.template.ts](capture-spec.template.ts) →
`src/core/tests/stubbed/ui-before-after.spec.ts` (it loops `targets.json`, seeds a
sample PDF so file-dependent panels render, navigates to each URL, and screenshots
the full viewport - or the `--scope` container if given). Ensure the harness is ready
(node_modules + icons).
```
# after = current head
cd frontend/editor && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# before = base, in an isolated worktree (copy the spec + targets.json in)
git worktree add ../ba-base origin/<baseRefName> # or the merge-base
# set up its frontend, copy spec + screenshots/ui-diff/targets.json across, then:
cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# copy its screenshots/ui-diff/before/ back next to after/. Repeat with
# PR_SHOT_THEME=dark if --theme includes dark. Remove worktree when done.
```
### 4. Auto-diff (surface what changed)
```
cd frontend/editor && node <skill>/diff-shots.mjs \
screenshots/ui-diff/before screenshots/ui-diff/after screenshots/ui-diff
```
Produces `diff-report.json` classifying each view `unchanged | changed | added |
removed`. For each changed view it computes the bounding box of differing pixels and
writes cropped `__before_crop.png` / `__after_crop.png` / `__diff.png` to that region
(+ padding) - **unless** the change covers more than `--pagewide` of the frame, where
it keeps the full frame (`pageWide:true`). Drop `unchanged` - that's the noise the
user doesn't want.
### 5. Montage the changes
Build the manifest from the non-unchanged entries (group by tab/tool; each becomes a
state row with before/after). For changed views use the cropped `cropBefore` /
`cropAfter` from `diff-report.json` (tight on the affected region; full frame when
`pageWide`); `added`/`removed` render the "not present" placeholder. Fill
[montage-template.html](montage-template.html) (replace the `window.__BA__` data
block; base64-inline the PNGs for portability), then render one PNG per section with
[shoot-sections.mjs](shoot-sections.mjs). Optionally include the `__diff.png` overlay
as a third column.
### 6. Deliver
Output the `montage_<tab>.png` files + a short summary (N changed / added / removed,
M unchanged skipped) and a paste-ready Markdown block. GitHub has no PR-body image
API, so tell the user to drag the PNGs into the description. Do **not** post to the
PR.
## Gotchas
- Two installs (base worktree + head); junction main's node_modules only if its deps
match that ref, else `npm ci` (see ui-walkthrough's stale-dep note).
- A view that errors on one side (refactored/removed) → that side is missing; the
diff marks it added/removed rather than failing the run.
- Pixel diff needs equal dimensions, so capture at a fixed viewport (the template
does); a view whose size changed is reported as "changed (dimensions differ)",
uncropped.
- Auto-crop uses a single bounding box, so two far-apart changes give one large crop
(or trip `--pagewide`); narrow with `--scope` if that happens.
- `getToolUrlPath` is the source of truth for tool URLs - use it, don't guess slugs.
- Don't commit `screenshots/`, the throwaway spec, or the base worktree.
@@ -0,0 +1,67 @@
// Generic before/after capturer. NOT app-specific: it walks a targets.json that
// the ui-before-after skill generates from the branch/PR diff, so nothing here is
// hand-listed. Copy to src/core/tests/stubbed/ui-before-after.spec.ts, then run
// once per (side, theme):
// PR_SHOT_SIDE=after PR_SHOT_THEME=light \
// npx playwright test --project=stubbed ui-before-after.spec.ts
//
// targets.json shape: [{ "id":"compress", "url":"/compress", "name":"Compress",
// "needsFile": true }]
import { test } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
const SIDE = process.env.PR_SHOT_SIDE ?? "after";
const THEME = process.env.PR_SHOT_THEME ?? "light";
// Capture the full viewport by default so the affected region is in frame
// wherever it is; diff-shots.mjs crops each comparison to what actually changed.
// Set PR_SHOT_SCOPE to a selector to narrow the capture to one container.
const SCOPE = process.env.PR_SHOT_SCOPE ?? "";
const ROOT = path.resolve(process.cwd(), "screenshots", "ui-diff");
const OUT = path.join(ROOT, SIDE);
// A tiny sample PDF so file-dependent tool panels render. Point at a real fixture.
const SAMPLE_PDF = process.env.PR_SHOT_SAMPLE ?? "src/core/tests/test-fixtures/sample.pdf";
type Target = { id: string; url: string; name?: string; needsFile?: boolean };
const targets: Target[] = JSON.parse(fs.readFileSync(path.join(ROOT, "targets.json"), "utf-8"));
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 }, seedJwt: true });
async function applyTheme(page: Page): Promise<void> {
if (THEME !== "dark") return;
await page.addInitScript(() => {
localStorage.setItem("mantine-color-scheme", "dark");
localStorage.setItem("mantine-color-scheme-value", "dark");
});
await page.emulateMedia({ colorScheme: "dark" });
}
async function seedFile(page: Page): Promise<void> {
if (!fs.existsSync(SAMPLE_PDF)) return;
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("files-button").click().catch(() => {});
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF).catch(() => {});
await page.locator(".file-sidebar-file-item").first().isVisible({ timeout: 8_000 }).catch(() => {});
}
for (const t of targets) {
// One test per target so a single failure doesn't drop the rest.
test(`${SIDE}/${THEME} ${t.id}`, async ({ page }) => {
fs.mkdirSync(OUT, { recursive: true });
await applyTheme(page);
if (t.needsFile !== false) await seedFile(page);
await page.goto(t.url, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(400); // settle Mantine portals/transitions
const shot = path.join(OUT, `${t.id}__${THEME}.png`);
if (SCOPE) {
const scope = page.locator(SCOPE).first();
if (await scope.isVisible({ timeout: 8_000 }).catch(() => false)) {
await scope.screenshot({ path: shot });
return;
}
}
// Full viewport (fixed size → stable dimensions for pixel diffing).
await page.screenshot({ path: shot });
});
}
@@ -0,0 +1,106 @@
// Auto-diff before/ vs after/ screenshots, classify each as
// unchanged | changed | added | removed, and CROP each changed pair to the
// affected region (bounding box of differing pixels + padding) - unless the
// change spans most of the page, in which case the full frame is kept.
// Run from frontend/editor (so deps resolve):
// node <skill>/diff-shots.mjs <beforeDir> <afterDir> [outDir]
// Env:
// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001)
// DIFF_PAD padding px around the affected region (default 24)
// DIFF_PAGEWIDE if affected bbox area / image area exceeds this, keep full frame (default 0.6)
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
const require = createRequire(path.join(process.cwd(), "noop.js"));
const pm = require("pixelmatch");
const pixelmatch = pm.default || pm;
const { PNG } = require("pngjs");
const beforeDir = path.resolve(process.argv[2]);
const afterDir = path.resolve(process.argv[3]);
const outDir = path.resolve(process.argv[4] || afterDir);
const THRESHOLD = Number(process.env.DIFF_THRESHOLD ?? "0.001");
const PAD = Number(process.env.DIFF_PAD ?? "24");
const PAGEWIDE = Number(process.env.DIFF_PAGEWIDE ?? "0.6");
const read = (p) => PNG.sync.read(fs.readFileSync(p));
const isShot = (f) => f.endsWith(".png") && !/__(diff|before_crop|after_crop)\.png$/.test(f);
const list = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter(isShot) : []);
const names = [...new Set([...list(beforeDir), ...list(afterDir)])].sort();
fs.mkdirSync(outDir, { recursive: true });
function cropPNG(src, x, y, w, h) {
const out = new PNG({ width: w, height: h });
PNG.bitblt(src, out, x, y, w, h, 0, 0);
return out;
}
const writePNG = (p, png) => fs.writeFileSync(p, PNG.sync.write(png));
// Bounding box of differing pixels using a diff mask (alpha>0 where changed).
function changedBBox(before, after, w, h) {
const mask = new PNG({ width: w, height: h });
pixelmatch(before.data, after.data, mask.data, w, h, { threshold: 0.1, diffMask: true });
let minX = w, minY = h, maxX = -1, maxY = -1, count = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (mask.data[(y * w + x) * 4 + 3] > 0) {
count++;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
}
}
return maxX < 0 ? null : { minX, minY, maxX, maxY, count };
}
const report = [];
for (const name of names) {
const id = name.replace(/\.png$/, "");
const bp = path.join(beforeDir, name), ap = path.join(afterDir, name);
const hasB = fs.existsSync(bp), hasA = fs.existsSync(ap);
if (hasB && !hasA) { report.push({ id, status: "removed", before: bp }); continue; }
if (!hasB && hasA) { report.push({ id, status: "added", after: ap }); continue; }
const before = read(bp), after = read(ap);
if (before.width !== after.width || before.height !== after.height) {
report.push({ id, status: "changed", note: "dimensions differ", before: bp, after: ap });
continue;
}
const w = after.width, h = after.height;
const overlay = new PNG({ width: w, height: h });
const px = pixelmatch(before.data, after.data, overlay.data, w, h, { threshold: 0.1 });
const ratio = px / (w * h);
if (ratio <= THRESHOLD) { report.push({ id, status: "unchanged", ratio: Number(ratio.toFixed(5)), before: bp, after: ap }); continue; }
const box = changedBBox(before, after, w, h);
// Pad + clamp the affected region.
const x = Math.max(0, box.minX - PAD), y = Math.max(0, box.minY - PAD);
const x2 = Math.min(w, box.maxX + 1 + PAD), y2 = Math.min(h, box.maxY + 1 + PAD);
const bw = x2 - x, bh = y2 - y;
const pageWide = (bw * bh) / (w * h) > PAGEWIDE;
const entry = { id, status: "changed", ratio: Number(ratio.toFixed(5)), before: bp, after: ap, pageWide };
if (pageWide) {
// Change spans most of the page - keep the full frame, full overlay.
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, overlay);
entry.diff = dp;
} else {
entry.bbox = { x, y, w: bw, h: bh };
const cb = path.join(outDir, `${id}__before_crop.png`); writePNG(cb, cropPNG(before, x, y, bw, bh));
const ca = path.join(outDir, `${id}__after_crop.png`); writePNG(ca, cropPNG(after, x, y, bw, bh));
const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, cropPNG(overlay, x, y, bw, bh));
entry.cropBefore = cb; entry.cropAfter = ca; entry.diff = dp;
}
report.push(entry);
}
fs.writeFileSync(path.join(outDir, "diff-report.json"), JSON.stringify(report, null, 2));
const changed = report.filter((r) => r.status !== "unchanged");
console.log(`diffed ${report.length} view(s): ${changed.length} changed/added/removed, ${report.length - changed.length} unchanged`);
for (const r of changed) {
const tail = r.status !== "changed" ? ""
: r.pageWide ? " (page-wide → full frame)"
: ` (${(r.ratio * 100).toFixed(2)}%, cropped to ${r.bbox.w}×${r.bbox.h})`;
console.log(` ${r.status.padEnd(9)} ${r.id}${tail}${r.note ? " - " + r.note : ""}`);
}
@@ -0,0 +1,48 @@
"""Build EXAMPLE.html from montage-template.html using REAL files-page shots as
stand-in before/after pairs (layout demo, not an actual PR diff). Inlines PNGs as
data URIs so the HTML is portable. Run: python make_example.py"""
import base64
import json
import pathlib
import re
HERE = pathlib.Path(__file__).parent
SHOTS = pathlib.Path(
r"C:\Users\systo\git\Stirling-PDFNew\.claude\worktrees\kind-faraday-522a30"
r"\frontend\editor\screenshots\files-page"
)
def uri(fname):
p = SHOTS / fname
return "data:image/png;base64," + base64.b64encode(p.read_bytes()).decode() if p.exists() else None
data = {
"pr": "DEMO",
"title": "EXAMPLE — before/after montage (layout demo, real Files-page shots; not a real PR diff)",
"base": "main", "head": "demo-branch",
"cropSelector": "[data-sidebar=\"tool-panel\"] (real runs crop to the side; these demo shots are full-page)",
"tabs": [
{"id": "files", "title": "Files page", "ctx": "Each row = one flow state; left = base branch, right = this PR.",
"states": [
{"name": "Empty folder", "before": uri("01_empty_state_ctas.png"), "after": uri("02_empty_state_storage_off.png")},
{"name": "Files + details panel", "before": uri("03_subtoolbar_with_files.png"), "after": uri("06_details_panel_save_to_server.png")},
{"name": "Delete folder confirm", "before": None, "after": uri("19_delete_folder_dialog.png"), "note": "New in this PR"},
]},
{"id": "move", "title": "Move-to-folder dialog",
"states": [
{"name": "Dialog opened", "before": uri("07_move_dialog_collapsed.png"), "after": uri("08_move_dialog_create_folder_expanded.png")},
{"name": "After folder created", "before": None, "after": uri("08b_move_dialog_after_create_folder.png"), "note": "New flow"},
]},
],
}
tpl = (HERE / "montage-template.html").read_text(encoding="utf-8")
out = re.sub(
r"/\*__DATA__\*/.*?/\*__END__\*/",
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
tpl, count=1, flags=re.S,
)
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
print("wrote", HERE / "EXAMPLE.html", "(", (HERE / "EXAMPLE.html").stat().st_size // 1024, "KB )")
@@ -0,0 +1,106 @@
<!doctype html>
<!--
Before/After montage for a PR description. The ui-before-after skill replaces
the JSON in the window.__BA__ data block below with the captured manifest, then
screenshots each .tab-section (id="section-<tabId>") into a PNG to drag into the
PR description. Self-contained; images may be relative paths or data URIs.
Data shape:
{
"pr":"6552","title":"...","base":"main","head":"feat/x",
"cropSelector":"[data-sidebar=\"tool-panel\"]",
"tabs":[
{ "id":"sign","title":"Sign tool","states":[
{"name":"Initial","before":"before/sign__initial.png","after":"after/sign__initial.png"},
{"name":"Cert selected","before":null,"after":"after/sign__cert.png","note":"New in this PR"}
]}
]
}
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Before / After</title>
<style>
:root { --bg:#ffffff; --ink:#0b0c0e; --muted:#6b7280; --line:#e5e7eb;
--before:#6b7280; --after:#1f883d; --frame:#f3f4f6; --note:#b45309; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink);
font:14px/1.5 -apple-system,"Segoe UI",Roboto,system-ui,sans-serif; }
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
.doc-head { margin-bottom:8px; }
.doc-head h1 { font-size:18px; margin:0 0 2px; }
.doc-head .sub { color:var(--muted); font-size:12.5px; }
.legend { display:flex; gap:14px; align-items:center; margin:10px 0 4px; font-size:12px; color:var(--muted); }
.chip { font-size:10px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
padding:2px 8px; border-radius:999px; color:#fff; }
.chip.before { background:var(--before); } .chip.after { background:var(--after); }
.tab-section { border:1px solid var(--line); border-radius:14px; padding:18px 18px 8px;
margin:18px 0; background:var(--bg); }
.tab-section > h2 { font-size:16px; margin:0 0 2px; }
.tab-section > .ctx { color:var(--muted); font-size:12px; margin-bottom:14px; }
.state { margin-bottom:18px; }
.state .name { font-weight:600; font-size:13.5px; margin-bottom:8px; display:flex; gap:8px; align-items:center; }
.state .name .note { font-weight:500; color:var(--note); font-size:12px; }
.pair { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:start; }
.cell { border:1px solid var(--line); border-radius:10px; overflow:hidden; background:var(--frame); }
.cell .cap { display:flex; align-items:center; gap:8px; padding:7px 10px; border-bottom:1px solid var(--line);
background:var(--bg); }
.cell .cap .meta { color:var(--muted); font-size:11px; }
.cell img { display:block; width:100%; height:auto; background:#fff; }
.cell.empty .ph { display:flex; align-items:center; justify-content:center; height:160px; color:var(--muted);
font-size:12.5px; text-align:center; padding:0 16px; }
.single .pair { grid-template-columns:1fr; }
.empty-doc { color:var(--muted); padding:40px; text-align:center; }
@media (max-width:760px){ .pair{ grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="wrap" id="root"></div>
<script id="data">
window.__BA__ = /*__DATA__*/{"pr":"","title":"No data","base":"","head":"","cropSelector":"","tabs":[]}/*__END__*/;
</script>
<script>
(function(){
var D = window.__BA__ || { tabs: [] };
var root = document.getElementById("root");
function el(html){ var t=document.createElement("template"); t.innerHTML=html.trim(); return t.content.firstChild; }
function esc(s){ return (s==null?"":String(s)).replace(/[&<>]/g, function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;"}[c];}); }
function cell(kind, src){
if (src) {
return '<div class="cell"><div class="cap"><span class="chip '+kind+'">'+kind+'</span></div>'+
'<img src="'+esc(src)+'" alt="'+kind+'"/></div>';
}
return '<div class="cell empty"><div class="cap"><span class="chip '+kind+'">'+kind+'</span>'+
'<span class="meta">not present</span></div><div class="ph">No '+kind+' screenshot for this state</div></div>';
}
var head = '<div class="doc-head"><h1>'+esc(D.title || ("PR #"+D.pr))+'</h1>'+
'<div class="sub">Before / after &nbsp;·&nbsp; base <code>'+esc(D.base)+'</code> → head <code>'+esc(D.head)+'</code>'+
(D.cropSelector ? ' &nbsp;·&nbsp; cropped to <code>'+esc(D.cropSelector)+'</code>' : '')+'</div></div>'+
'<div class="legend"><span class="chip before">Before</span> base branch'+
'<span class="chip after">After</span> this PR</div>';
root.appendChild(el('<div>'+head+'</div>'));
if (!D.tabs || !D.tabs.length){ root.appendChild(el('<div class="empty-doc">No tabs captured yet.</div>')); return; }
D.tabs.forEach(function(tab){
var states = (tab.states||[]).map(function(s){
var onlyOne = (!s.before || !s.after);
return '<div class="state'+(onlyOne?' ':'')+'">'+
'<div class="name">'+esc(s.name)+(s.note?'<span class="note">'+esc(s.note)+'</span>':'')+'</div>'+
'<div class="pair">'+cell("before", s.before)+cell("after", s.after)+'</div></div>';
}).join("");
var sec = '<section class="tab-section" id="section-'+esc(tab.id)+'">'+
'<h2>'+esc(tab.title)+'</h2>'+
(tab.ctx?'<div class="ctx">'+esc(tab.ctx)+'</div>':'')+
states+'</section>';
root.appendChild(el(sec));
});
})();
</script>
</body>
</html>
@@ -0,0 +1,25 @@
// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image).
// Run from frontend/editor (so @playwright/test resolves):
// node <skill>/shoot-sections.mjs <montage.html> <outDir>
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createRequire } from "node:module";
const require = createRequire(path.join(process.cwd(), "noop.js"));
const { chromium } = require("@playwright/test");
const htmlPath = path.resolve(process.argv[2]);
const outDir = path.resolve(process.argv[3] || path.dirname(htmlPath));
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 1200 }, deviceScaleFactor: 2 });
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
await page.waitForTimeout(250); // let images/fonts paint
const ids = await page.$$eval(".tab-section", (els) => els.map((e) => e.id));
if (!ids.length) { console.error("no .tab-section found"); process.exit(1); }
for (const id of ids) {
const name = id.replace(/^section-/, "");
await page.locator("#" + id).screenshot({ path: path.join(outDir, `montage_${name}.png`) });
console.log("wrote montage_" + name + ".png");
}
await browser.close();
+120
View File
@@ -0,0 +1,120 @@
---
name: ui-walkthrough
description: >-
Full UI investigation of the current branch's feature. Enumerates every view
and state (empty, populated, loading, error, each dialog/menu/panel, responsive
breakpoints, light + dark + RTL), captures them with the stubbed Playwright
harness, assembles a single-image HTML walkthrough with a global light/dark
toggle slider, then runs two review passes: visual/consistency (alignment,
spacing, professionalism, dark/light parity, contrast, truncation) and
UX/ease-of-use (flow, discoverability, affordances, empty/error states,
expectations). Use when asked for a UI walkthrough, screenshot review, design
or QA pass, "find anywhere to make it easier/better for users", or before
merging frontend work. Pass --fix to auto-apply safe frontend fixes and
re-capture; --theme to limit themes; --no-rtl to skip RTL.
argument-hint: "[feature/area] [--fix] [--theme light|dark|both] [--no-rtl] [--breakpoints]"
allowed-tools: Read, Write, Edit, Glob, Grep, Bash
---
# UI Walkthrough
Produce a reviewable HTML walkthrough of a feature's UI in every state and theme,
then critique it. Optionally auto-fix and re-capture.
`$ARGUMENTS` may name the feature/area to focus on. If empty, scope from the
current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
`--no-rtl`, `--breakpoints` (also capture phone/narrow widths).
## What this repo gives you (use it, don't reinvent)
- **Stubbed Playwright project** = backend-free screenshots via `page.route()` mocks.
Reference implementation: `frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts`.
It already shows the light / **dark** / **RTL** passes, JWT seeding, IndexedDB
seeding, and dumping PNGs to a `screenshots/<area>/` folder. Copy its shape.
- Helpers: `frontend/editor/src/core/tests/helpers/ui-helpers.ts`
(`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …)
and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`).
- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`).
- Report template: [report-template.html](report-template.html) - self-contained,
one big image at a time, a global light/dark slider that flips every shot,
thumbnail rail, prev/next + arrow keys, and a Findings tab.
## Process
### 1. Scope the feature
- If `$ARGUMENTS` is empty: `git diff --name-only main...HEAD` and read the PR/commits.
Identify changed pages, tools (`core/components/tools/<tool>` or `core/tools/<tool>`),
dialogs, panels, and routes.
- Enumerate **every view and state** to capture, e.g.:
empty / populated / loading / error / disabled; each dialog, menu, popover, tooltip;
each tab or step; selection + multi-select; success/result panel; and (if relevant)
permission/role variants. Write the list down before capturing - it's the report's spine.
### 2. Prepare the harness (worktree-safe)
Worktrees have no `node_modules` and no generated icons. From repo root:
```
cd frontend && npm ci # or junction main's node_modules (see memory)
cd frontend/editor && node scripts/generate-icons.js
```
Kill any stale dev server first (it serves old modules):
`Get-NetTCPConnection -LocalPort 5173 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`
### 3. Write the capture spec
Create `frontend/editor/src/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
- stub the APIs it needs, drive the UI to that state, wait on a real locator
(not a fixed sleep), `await settle(page)` for Mantine portals, then
`page.screenshot({ path: shotPath("NN_name_<theme>") })`.
- Capture each view in **light and dark** (and RTL unless `--no-rtl`). Reuse the
`enableDarkMode` / `enableRtl` init-script pattern from the reference spec
(`localStorage["mantine-color-scheme"]="dark"` + `emulateMedia({colorScheme:"dark"})`).
- Name shots `NN_<view>_<theme>.png` so light/dark pair up by suffix.
- Prefer **stable test-ids** over translated accessible names (RTL/i18n breaks text locators).
Run it: `cd frontend/editor && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters.
### 4. Build the report
- Copy `report-template.html` to `screenshots/<feature>/walkthrough.html` (so the
relative `screenshots/...` image paths resolve, or rewrite paths to sit beside it).
- Build the manifest and inject it: replace the JSON between the
`/*__DATA__*/``/*__END__*/` markers with one `views[]` entry per view
(`{id,title,light,dark,viewport,notes}`) and an empty `findings` object you'll
fill in step 5. Keep `light`/`dark` as relative paths.
- The toggle slider answers the "one big image + flip light/dark for all" request:
it shows a single large screenshot, and switching the slider re-themes every view.
### 5. Review pass 1 - visual & consistency
Open each screenshot (Read the PNG) and judge against the others:
alignment & spacing rhythm, control placement, button hierarchy, typography,
**light/dark parity** (contrast, invisible borders, washed-out text, wrong tokens),
truncation/overflow, RTL mirroring, focus states, icon consistency, professional polish.
Record each issue as a finding `{severity:high|med|low, view, title, detail, fix}`.
### 6. Review pass 2 - UX & ease of use
Walk the flow as a first-time user: discoverability, number of steps, affordance
clarity, empty-state guidance, error recovery, destructive-action confirmation,
defaults, loading feedback, mobile reachability, accessible names, and whether the
UI matches user expectations for this kind of tool. Record findings the same way.
Write both finding lists into the report's `findings.visual` / `findings.ux`,
and add short per-view `notes`. Re-inject the manifest.
### 7. If `--fix`
Only safe, self-contained frontend fixes (spacing, alignment, tokens, missing
dark-mode colors, labels, aria, obvious copy). For each: edit the component/CSS,
mark the finding `fixed:true` with what changed, then **re-run the spec** to
re-capture the affected shots and regenerate the report. Run `task frontend:check`.
Leave anything risky or ambiguous as a finding, not a change.
### 8. Deliver
Tell the user the report path and give a tight chat summary: N views ×
themes captured, top findings by severity, and (if `--fix`) what changed.
Optionally `SendUserFile` the `walkthrough.html`.
## Gotchas
- Stale `:5173` server serves old bundles - kill it before capturing (see step 2).
- Missing `material-symbols-icons.json` → blank app → every shot times out. Run
`generate-icons.js` first.
- `await settle(page)` before shots or portals/transitions tear mid-capture.
- Don't commit the generated `screenshots/` or the throwaway spec unless asked.
@@ -0,0 +1,116 @@
"""Build a self-contained EXAMPLE.html from report-template.html with mock
light/dark screenshots, so the viewer + global theme slider can be demoed
without a real capture run. Run: python make_example.py"""
import base64
import json
import pathlib
import re
HERE = pathlib.Path(__file__).parent
def svg(bg, fg, panel, accent, muted, label, kind):
"""A simple fake 'screen' SVG: title bar, sidebar, content varies by kind."""
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">',
f'<rect width="1600" height="900" fill="{bg}"/>',
# top bar
f'<rect width="1600" height="64" fill="{panel}"/>',
f'<circle cx="40" cy="32" r="12" fill="{accent}"/>',
f'<rect x="64" y="24" width="160" height="16" rx="6" fill="{muted}"/>',
f'<rect x="1430" y="20" width="130" height="24" rx="12" fill="{accent}"/>',
# left sidebar
f'<rect x="0" y="64" width="220" height="836" fill="{panel}"/>',
]
for i in range(6):
y = 100 + i * 56
parts.append(f'<rect x="24" y="{y}" width="172" height="32" rx="8" fill="{bg}"/>')
if kind == "empty":
parts += [
f'<rect x="700" y="360" width="200" height="120" rx="16" fill="none" stroke="{muted}" stroke-width="3" stroke-dasharray="10 8"/>',
f'<rect x="690" y="510" width="220" height="44" rx="10" fill="{accent}"/>',
f'<text x="800" y="600" fill="{muted}" font-family="sans-serif" font-size="26" text-anchor="middle">{label}</text>',
]
elif kind == "form":
for i in range(4):
y = 140 + i * 90
parts.append(f'<rect x="280" y="{y}" width="160" height="16" rx="6" fill="{muted}"/>')
parts.append(f'<rect x="280" y="{y+26}" width="900" height="44" rx="8" fill="{panel}" stroke="{muted}" stroke-width="1"/>')
parts.append(f'<rect x="280" y="560" width="200" height="50" rx="10" fill="{accent}"/>')
parts.append(f'<text x="800" y="850" fill="{muted}" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>')
else: # dialog
parts += [
f'<rect width="1600" height="900" fill="{fg}" opacity="0.45"/>',
f'<rect x="520" y="280" width="560" height="360" rx="18" fill="{panel}"/>',
f'<rect x="556" y="320" width="280" height="22" rx="8" fill="{fg}"/>',
f'<rect x="556" y="372" width="488" height="14" rx="6" fill="{muted}"/>',
f'<rect x="556" y="398" width="420" height="14" rx="6" fill="{muted}"/>',
f'<rect x="820" y="560" width="110" height="44" rx="9" fill="{bg}" stroke="{muted}"/>',
f'<rect x="946" y="560" width="98" height="44" rx="9" fill="{accent}"/>',
f'<text x="800" y="700" fill="#fff" font-family="sans-serif" font-size="24" text-anchor="middle">{label}</text>',
]
parts.append("</svg>")
return "".join(parts)
def data_uri(s):
return "data:image/svg+xml;base64," + base64.b64encode(s.encode()).decode()
LIGHT = dict(bg="#ffffff", fg="#111418", panel="#f1f3f6", accent="#2f6fed", muted="#c2c8d0")
DARK = dict(bg="#16181c", fg="#000000", panel="#1f232a", accent="#5b8cff", muted="#3a414b")
def pair(kind, label):
return (
data_uri(svg(LIGHT["bg"], LIGHT["fg"], LIGHT["panel"], LIGHT["accent"], LIGHT["muted"], label, kind)),
data_uri(svg(DARK["bg"], DARK["fg"], DARK["panel"], DARK["accent"], DARK["muted"], label, kind)),
)
views = []
for idx, (kind, title, label) in enumerate([
("empty", "Empty state", "Drop a PDF to start"),
("form", "Tool options panel", "Compress options"),
("dialog", "Confirm dialog", "Replace original file?"),
], start=1):
light, dark = pair(kind, label)
views.append({
"id": f"{idx:02d}_{kind}",
"title": title,
"light": light,
"dark": dark,
"viewport": "1600x900",
"notes": ["This is mock data to demo the viewer."],
})
data = {
"feature": "EXAMPLE - Compress PDF (mock data)",
"branch": "demo",
"generated": "example",
"views": views,
"findings": {
"visual": [
{"severity": "high", "view": "03_dialog", "title": "Dialog buttons too close",
"detail": "Cancel/Confirm have only 8px gap; easy to misclick.",
"fix": "Increase gap to var(--mantine-spacing-md)."},
{"severity": "low", "view": "02_form", "title": "Field labels low contrast in dark mode",
"detail": "Muted token fails WCAG AA on the dark panel.",
"fix": "Use --mantine-color-dimmed instead of a hard-coded grey."},
],
"ux": [
{"severity": "med", "view": "01_empty", "title": "Primary CTA below the dropzone",
"detail": "Users expect the action button adjacent to the dropzone.",
"fix": "Move the button directly under the dashed zone."},
],
},
}
tpl = (HERE / "report-template.html").read_text(encoding="utf-8")
out = re.sub(
r"/\*__DATA__\*/.*?/\*__END__\*/",
lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/",
tpl, count=1, flags=re.S,
)
(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8")
print("wrote", (HERE / "EXAMPLE.html"))
@@ -0,0 +1,298 @@
<!doctype html>
<!--
UI Walkthrough report template (self-contained, works from file://).
The ui-walkthrough skill replaces the JSON in the window.__WALKTHROUGH__ data
block below with the captured manifest. Do not add external CDN deps - it must open offline.
Data shape:
{
"feature": "Compress PDF tool",
"branch": "claude/...",
"generated": "2026-06-21",
"views": [
{ "id": "01_empty", "title": "Empty state",
"light": "screenshots/compress/01_empty_light.png",
"dark": "screenshots/compress/01_empty_dark.png",
"viewport": "1600x900",
"notes": ["Heading is centered", "Primary CTA below the fold on mobile"] }
],
"findings": {
"visual": [ { "severity":"high", "view":"01_empty", "title":"...", "detail":"...", "fix":"..." } ],
"ux": [ { "severity":"med", "view":"03_dialog", "title":"...", "detail":"...", "fix":"..." } ]
}
}
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UI Walkthrough</title>
<style>
:root {
--bg: #f6f7f9; --panel: #ffffff; --panel-2: #f0f2f5; --text: #1a1b1e;
--muted: #6b7280; --border: #e2e5ea; --accent: #2f6fed; --accent-weak: #e8f0fe;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.06);
--hi: #d92d20; --med: #d98e00; --low: #2f6fed; --stage: #0b0c0e;
}
html[data-theme="dark"] {
--bg: #0d0e10; --panel: #16181c; --panel-2: #1d2024; --text: #e6e8eb;
--muted: #9aa3ad; --border: #2a2e35; --accent: #5b8cff; --accent-weak: #1a2336;
--shadow: 0 1px 3px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.4); --stage: #000;
}
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.5 -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
background: var(--bg); color: var(--text); }
header { display: flex; align-items: center; gap: 16px; padding: 12px 20px;
background: var(--panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 5; }
header h1 { font-size: 15px; margin: 0; font-weight: 650; }
header .sub { color: var(--muted); font-size: 12px; }
.spacer { flex: 1; }
.counter { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
.tabs { display: flex; gap: 4px; }
.tab { border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 13px; }
.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Light/Dark slider */
.theme-toggle { display: flex; align-items: center; gap: 9px; user-select: none; }
.theme-toggle .lbl { font-size: 12px; color: var(--muted); }
.theme-toggle .lbl.on { color: var(--text); font-weight: 600; }
.switch { position: relative; width: 52px; height: 28px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; inset: 0; cursor: pointer; background: var(--panel-2);
border: 1px solid var(--border); border-radius: 999px; transition: .2s; }
.slider:before { content: ""; position: absolute; height: 20px; width: 20px; left: 3px; top: 3px;
background: #fbbf24; border-radius: 50%; transition: .2s; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider:before { transform: translateX(24px); background: #c7d2fe; }
main { display: grid; grid-template-columns: 240px 1fr; height: calc(100vh - 53px); }
.rail { border-right: 1px solid var(--border); overflow-y: auto; background: var(--panel); padding: 8px; }
.rail .group-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); padding: 10px 8px 4px; }
.thumb { display: flex; gap: 9px; align-items: center; padding: 7px; border-radius: 8px;
cursor: pointer; border: 1px solid transparent; }
.thumb:hover { background: var(--panel-2); }
.thumb.active { background: var(--accent-weak); border-color: var(--accent); }
.thumb img { width: 64px; height: 40px; object-fit: cover; border-radius: 4px; border: 1px solid var(--border); background: var(--stage); }
.thumb .t { font-size: 12.5px; line-height: 1.3; }
.thumb .badge { font-size: 10px; color: var(--muted); }
.thumb .dot { width: 7px; height: 7px; border-radius: 50%; margin-left: auto; flex: none; }
.stagewrap { display: flex; flex-direction: column; min-width: 0; }
.stage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 22px;
background: var(--stage); position: relative; min-height: 0; }
.stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px;
box-shadow: 0 4px 30px rgba(0,0,0,.4); background: #fff; }
html[data-theme="dark"] .stage img { background: #16181c; }
.nav-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 42px; height: 42px;
border-radius: 50%; border: 1px solid var(--border); background: var(--panel);
color: var(--text); cursor: pointer; font-size: 18px; opacity: .85; }
.nav-btn:hover { opacity: 1; } .nav-btn.prev { left: 16px; } .nav-btn.next { right: 16px; }
.nav-btn:disabled { opacity: .25; cursor: default; }
.missing { color: var(--muted); font-size: 13px; text-align: center; }
.detail { border-top: 1px solid var(--border); background: var(--panel); padding: 14px 20px;
max-height: 38vh; overflow-y: auto; }
.detail h2 { margin: 0 0 4px; font-size: 15px; }
.detail .meta { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
.notes { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
.notes li { display: flex; gap: 8px; align-items: flex-start; }
.sev { font-size: 10px; font-weight: 700; text-transform: uppercase; padding: 2px 7px; border-radius: 999px;
color: #fff; flex: none; margin-top: 1px; }
.sev.high { background: var(--hi); } .sev.med { background: var(--med); } .sev.low { background: var(--low); }
.finding .fix { color: var(--muted); font-size: 12.5px; }
.finding .fix b { color: var(--text); font-weight: 600; }
/* Summary tab */
.summary { padding: 20px 28px; overflow-y: auto; }
.summary h2 { font-size: 16px; margin: 22px 0 8px; }
.summary .empty { color: var(--muted); }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 14px; margin-bottom: 8px; box-shadow: var(--shadow); }
.card .head { display: flex; gap: 8px; align-items: center; }
.card a { color: var(--accent); text-decoration: none; cursor: pointer; }
.hide { display: none !important; }
kbd { font: 11px ui-monospace, monospace; background: var(--panel-2); border: 1px solid var(--border);
border-radius: 4px; padding: 1px 5px; }
</style>
</head>
<body>
<header>
<div>
<h1 id="feature-title">UI Walkthrough</h1>
<div class="sub" id="feature-sub"></div>
</div>
<div class="spacer"></div>
<div class="tabs">
<button class="tab active" data-tab="viewer">Walkthrough</button>
<button class="tab" data-tab="summary">Findings</button>
</div>
<div class="counter" id="counter"></div>
<label class="theme-toggle" title="Toggle light / dark for every screenshot">
<span class="lbl" id="lbl-light">Light</span>
<span class="switch"><input type="checkbox" id="theme-switch" /><span class="slider"></span></span>
<span class="lbl" id="lbl-dark">Dark</span>
</label>
</header>
<main id="viewer-pane">
<aside class="rail" id="rail"></aside>
<section class="stagewrap">
<div class="stage">
<button class="nav-btn prev" id="prev" aria-label="Previous">&#8249;</button>
<img id="stage-img" alt="" />
<div class="missing hide" id="missing"></div>
<button class="nav-btn next" id="next" aria-label="Next">&#8250;</button>
</div>
<div class="detail">
<h2 id="view-title"></h2>
<div class="meta" id="view-meta"></div>
<ul class="notes" id="view-notes"></ul>
</div>
</section>
</main>
<section class="summary hide" id="summary-pane"></section>
<script id="data">
window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated":"","views":[],"findings":{"visual":[],"ux":[]}}/*__END__*/;
</script>
<script>
(function () {
var D = window.__WALKTHROUGH__ || { views: [], findings: { visual: [], ux: [] } };
var views = D.views || [];
var state = { i: 0, theme: localStorage.getItem("ui-wt-theme") || "light", tab: "viewer" };
var $ = function (id) { return document.getElementById(id); };
function sevClass(s) { return s === "high" ? "high" : s === "med" || s === "medium" ? "med" : "low"; }
function applyChrome() {
document.documentElement.setAttribute("data-theme", state.theme);
$("theme-switch").checked = state.theme === "dark";
$("lbl-light").classList.toggle("on", state.theme === "light");
$("lbl-dark").classList.toggle("on", state.theme === "dark");
}
function srcFor(v) { return state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark); }
function findingsForView(id) {
var all = (D.findings && D.findings.visual || []).concat(D.findings && D.findings.ux || []);
return all.filter(function (f) { return f.view === id; });
}
function renderRail() {
var rail = $("rail");
rail.innerHTML = "";
if (!views.length) { rail.innerHTML = '<div class="group-label">No views captured</div>'; return; }
views.forEach(function (v, idx) {
var fs = findingsForView(v.id);
var worst = fs.some(function (f){return sevClass(f.severity)==="high";}) ? "var(--hi)"
: fs.some(function (f){return sevClass(f.severity)==="med";}) ? "var(--med)"
: fs.length ? "var(--low)" : "transparent";
var el = document.createElement("div");
el.className = "thumb" + (idx === state.i ? " active" : "");
el.innerHTML = '<img src="' + srcFor(v) + '" alt="" />' +
'<div><div class="t">' + (v.title || v.id) + '</div>' +
'<div class="badge">' + (v.viewport || "") + '</div></div>' +
'<span class="dot" style="background:' + worst + '"></span>';
el.onclick = function () { state.i = idx; render(); };
rail.appendChild(el);
});
}
function render() {
applyChrome();
if (!views.length) {
$("missing").classList.remove("hide"); $("stage-img").classList.add("hide");
$("missing").textContent = "No screenshots in this report yet.";
$("counter").textContent = ""; return;
}
var v = views[state.i];
var src = srcFor(v);
var img = $("stage-img");
if (src) {
img.classList.remove("hide"); $("missing").classList.add("hide");
img.src = src; img.alt = v.title || v.id;
} else {
img.classList.add("hide"); $("missing").classList.remove("hide");
$("missing").textContent = "No " + state.theme + " screenshot for this view.";
}
$("counter").textContent = (state.i + 1) + " / " + views.length;
$("view-title").textContent = v.title || v.id;
$("view-meta").textContent = [v.viewport, state.theme + " mode"].filter(Boolean).join(" · ");
var notes = $("view-notes"); notes.innerHTML = "";
var fs = findingsForView(v.id);
(v.notes || []).forEach(function (n) {
var li = document.createElement("li"); li.textContent = "· " + n; notes.appendChild(li);
});
fs.forEach(function (f) {
var li = document.createElement("li"); li.className = "finding";
li.innerHTML = '<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
'<span><b>' + (f.title || "") + '</b> — ' + (f.detail || "") +
(f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
notes.appendChild(li);
});
$("prev").disabled = state.i === 0;
$("next").disabled = state.i === views.length - 1;
renderRail();
}
function renderSummary() {
var pane = $("summary-pane");
function block(title, arr) {
var h = '<h2>' + title + ' (' + arr.length + ')</h2>';
if (!arr.length) return h + '<div class="empty">None found.</div>';
return h + arr.map(function (f) {
return '<div class="card"><div class="head">' +
'<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
'<b>' + (f.title || "") + '</b>' +
(f.view ? ' <a data-jump="' + f.view + '">' + f.view + '</a>' : '') + '</div>' +
'<div style="margin-top:6px">' + (f.detail || "") + '</div>' +
(f.fix ? '<div class="finding" style="margin-top:6px"><span class="fix"><b>Fix:</b> ' + f.fix + '</span></div>' : '') +
'</div>';
}).join("");
}
pane.innerHTML = block("Visual & consistency", (D.findings && D.findings.visual) || []) +
block("UX & ease of use", (D.findings && D.findings.ux) || []);
pane.querySelectorAll("[data-jump]").forEach(function (a) {
a.onclick = function () {
var id = a.getAttribute("data-jump");
var idx = views.findIndex(function (v) { return v.id === id; });
if (idx >= 0) { state.i = idx; setTab("viewer"); }
};
});
}
function setTab(t) {
state.tab = t;
document.querySelectorAll(".tab").forEach(function (b) { b.classList.toggle("active", b.dataset.tab === t); });
$("viewer-pane").classList.toggle("hide", t !== "viewer");
$("summary-pane").classList.toggle("hide", t !== "summary");
if (t === "viewer") $("viewer-pane").style.display = "grid";
if (t === "summary") renderSummary();
}
// wiring
$("feature-title").textContent = D.feature || "UI Walkthrough";
$("feature-sub").textContent = [D.branch, D.generated].filter(Boolean).join(" · ");
$("theme-switch").onchange = function () {
state.theme = this.checked ? "dark" : "light";
localStorage.setItem("ui-wt-theme", state.theme);
render();
};
$("prev").onclick = function () { if (state.i > 0) { state.i--; render(); } };
$("next").onclick = function () { if (state.i < views.length - 1) { state.i++; render(); } };
document.addEventListener("keydown", function (e) {
if (state.tab !== "viewer") return;
if (e.key === "ArrowLeft") $("prev").click();
if (e.key === "ArrowRight") $("next").click();
if (e.key.toLowerCase() === "t") $("theme-switch").click();
});
document.querySelectorAll(".tab").forEach(function (b) { b.onclick = function () { setTab(b.dataset.tab); }; });
render();
})();
</script>
</body>
</html>
+20 -4
View File
@@ -116,6 +116,9 @@ jobs:
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
# Single source of truth for whether this preview embeds the admin portal:
# drives the image build-arg and the deployment comment.
BUILD_PORTAL: "true"
steps:
- name: Harden Runner
@@ -246,7 +249,9 @@ jobs:
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: VERSION_TAG=v2-alpha
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
platforms: linux/amd64
- name: Build and push V2 image (Docker fork fallback)
@@ -259,7 +264,9 @@ jobs:
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: VERSION_TAG=v2-alpha
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
platforms: linux/amd64
- name: Set up SSH
@@ -290,6 +297,8 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
POLICIES_ENABLED: "true"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
@@ -359,12 +368,19 @@ jobs:
}
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
// Only mention the portal when this image actually embeds it.
// Use the direct IP URL - the SSL hostname isn't supported yet.
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
const portalNote = withPortal
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
: ``;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with embedded architecture has been deployed!\n\n` +
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
`🔐 **Secure HTTPS URL**: unsupported currently\n\n` +
portalNote +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** for approved V2 contributors.`;
@@ -57,6 +57,15 @@ public class RequestUriUtils {
return true;
}
// Admin portal SPA shell. Served publicly like the editor root so a direct
// nav / refresh to /portal loads the app (the JWT lives in localStorage, not
// a cookie, so the server can't authenticate the navigation itself). The
// portal gates access via its own auth gate + RequirePortalAccess, and its
// data APIs stay protected, so serving the shell pre-auth is safe.
if (normalizedUri.equals("/portal") || normalizedUri.startsWith("/portal/")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
@@ -73,6 +73,14 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
}
@Test
void testIsStaticResource_portalShell() {
// The admin portal SPA shell is served pre-auth so it's directly navigable.
assertTrue(RequestUriUtils.isStaticResource("/portal"));
assertTrue(RequestUriUtils.isStaticResource("/portal/users"));
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/portal"));
}
// --- isFrontendRoute tests ---
@Test
+11 -1
View File
@@ -175,6 +175,14 @@ springBoot {
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true'
// The admin portal ships as a lazy route inside the editor bundle (see
// proprietary/routes/adminRouteExtensions). -PbuildWithPortal=true includes that
// chunk via VITE_INCLUDE_PORTAL on the editor build; the deploy GHA sets it when
// the portal or AI layers change. Building the portal implies building the editor.
def buildWithPortal = project.hasProperty('buildWithPortal') && project.property('buildWithPortal') == 'true'
if (buildWithPortal) {
buildWithFrontend = true
}
// Workspace root holds package.json and node_modules (shared across editor /
// future portal). Editor-specific paths (src, public, dist, tauri) live one
// level deeper under frontend/editor/.
@@ -297,9 +305,11 @@ tasks.register('npmBuild', Exec) {
// Override VITE_API_BASE_URL to use relative paths for production builds
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
environment 'VITE_API_BASE_URL', '/'
// Include the admin portal's lazy route/chunk in the editor build when requested.
environment 'VITE_INCLUDE_PORTAL', (buildWithPortal ? 'true' : 'false')
doFirst {
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)"
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})"
}
}
+4
View File
@@ -42,6 +42,9 @@ COPY . .
ARG PROTOTYPES_BUILD=false
ARG STIRLING_FLAVOR=proprietary
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
# Embed the admin portal app at /portal. Set true by the deploy workflow when the
# portal or AI layers change; defaults false so normal builds skip the extra app.
ARG BUILD_PORTAL=false
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
@@ -49,6 +52,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
gradle clean build \
-PbuildWithFrontend=true \
-PbuildWithPortal=${BUILD_PORTAL} \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-PprototypesMode=${PROTOTYPES_BUILD} \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
+3
View File
@@ -40,12 +40,15 @@ RUN gradle dependencies --no-daemon || true
COPY . .
# Embed the admin portal app at /portal when the deploy workflow flags it.
ARG BUILD_PORTAL=false
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
DISABLE_ADDITIONAL_FEATURES=false \
gradle clean build \
-PbuildWithFrontend=true \
-PbuildWithPortal=${BUILD_PORTAL} \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
+3
View File
@@ -40,12 +40,15 @@ RUN ./gradlew dependencies --no-daemon || true
COPY . .
# Build ultra-lite JAR with embedded frontend (minimal features).
# Embed the admin portal app at /portal when the deploy workflow flags it.
ARG BUILD_PORTAL=false
# Bundle only the JPDFium native for this image's target arch.
ARG TARGETARCH
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
DISABLE_ADDITIONAL_FEATURES=true \
./gradlew clean build \
-PbuildWithFrontend=true \
-PbuildWithPortal=${BUILD_PORTAL} \
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
+1
View File
@@ -0,0 +1 @@
declare module "*.css" {}
+5
View File
@@ -61,6 +61,11 @@ const config: StorybookConfig = {
config.define = {
...(config.define ?? {}),
"import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"),
// Keep the Supabase auth env empty so ensureSaasSupabase() is a no-op and
// never replaces the mock SaaS client stubbed in preview.tsx.
"import.meta.env.VITE_SUPABASE_URL": JSON.stringify(""),
"import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY":
JSON.stringify(""),
};
return config;
},
+35 -27
View File
@@ -7,7 +7,6 @@ import type { Decorator, Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";
import { MemoryRouter } from "react-router-dom";
import { withThemeByDataAttribute } from "@storybook/addon-themes";
import { MantineProvider } from "@mantine/core";
// Reference React so the import isn't dropped as unused by the bundler — the
// classic runtime needs it present even though it's not named in the JSX.
@@ -15,9 +14,9 @@ void React;
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { ThemeProvider } from "@portal/contexts/ThemeContext";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme";
import { SuiProvider } from "@portal/theme/SuiProvider";
import { handlers } from "@portal/mocks/handlers";
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
@@ -30,10 +29,10 @@ initialize({ onUnhandledRequest: "bypass" }, handlers);
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
// method, wallet) clear the session check and reach the MSW handlers instead of
// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally
// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only
// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via
// .storybook/main.ts's viteFinal define, not a frontend/.env file.
// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see
// .storybook/main.ts), so ensureSaasSupabase() is a no-op and never replaces this
// client; only VITE_SAAS_API_URL (a mock origin MSW matches) is configured —
// injected via .storybook/main.ts's viteFinal define, not a frontend/.env file.
const saasStub = configureSupabase({
url: "http://saas.mock",
key: "storybook-anon-key",
@@ -79,13 +78,21 @@ function TierKey({
);
}
/** Keeps useTheme() and the data-theme attribute in sync. */
function ThemeWatcher() {
/**
* Makes the Storybook toolbar the SINGLE source of truth for the theme.
*/
function ThemeBridge({
theme,
children,
}: {
theme: "light" | "dark";
children: React.ReactNode;
}) {
const { setTheme } = useTheme();
useEffect(() => {
// The addon-themes decorator already sets data-theme on <html>.
// We just read it on mount so ThemeProvider picks it up.
}, []);
return null;
setTheme(theme);
}, [theme, setTheme]);
return <>{children}</>;
}
const withProviders: Decorator = (Story, context) => {
@@ -102,20 +109,21 @@ const withProviders: Decorator = (Story, context) => {
return (
<MemoryRouter initialEntries={["/"]}>
<ThemeProvider>
<MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}>
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
from useLink() (matches App.tsx's nesting). */}
<LinkProvider key={linkState} initialState={linkState}>
<TierKey tier={tier}>
<UIProvider>
<ThemeWatcher />
<Suspense fallback={null}>
<Story />
</Suspense>
</UIProvider>
</TierKey>
</LinkProvider>
</MantineProvider>
<ThemeBridge theme={colorScheme}>
<SuiProvider colorScheme={colorScheme}>
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
from useLink() (matches App.tsx's nesting). */}
<LinkProvider key={linkState} initialState={linkState}>
<TierKey tier={tier}>
<UIProvider>
<Suspense fallback={null}>
<Story />
</Suspense>
</UIProvider>
</TierKey>
</LinkProvider>
</SuiProvider>
</ThemeBridge>
</ThemeProvider>
</MemoryRouter>
);
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "es2022",
"jsx": "react-jsx",
"module": "esnext",
"moduleResolution": "bundler",
"paths": {
"@app/*": [
"../editor/src/desktop/*",
"../editor/src/proprietary/*",
"../editor/src/core/*"
],
"@core/*": ["../editor/src/core/*"],
"@proprietary/*": ["../editor/src/proprietary/*"],
"@portal/*": ["../editor/src/portal/*"]
},
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["./**/*"]
}
+4
View File
@@ -7,6 +7,10 @@
# API base URL — use / for same-origin (default for web builds)
VITE_API_BASE_URL=/
# Include the admin portal's lazy route/chunk in the build (set true by
# -PbuildWithPortal in the JAR). Off by default; always on in dev.
VITE_INCLUDE_PORTAL=false
# Google Drive integration
VITE_GOOGLE_DRIVE_CLIENT_ID=
VITE_GOOGLE_DRIVE_API_KEY=
-7
View File
@@ -18,13 +18,6 @@ VITE_EDITOR_URL=/
# backend.
VITE_PORTAL_MOCKS=
# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account
# linking (both values are public). Set per deploy; absent -> the account-link
# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase
# project the local backend links against.
VITE_SAAS_SUPABASE_URL=
VITE_SAAS_SUPABASE_ANON_KEY=
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for
# ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
# admin's Supabase JWT. Distinct from the local backend (reached same-origin via
@@ -7,6 +7,7 @@ black = "Black"
blue = "Blue"
cancel = "Cancel"
chooseFile = "Choose File"
clear = "Clear"
close = "Close"
comingSoon = "Coming soon"
confirm = "Confirm"
@@ -87,6 +88,7 @@ processingCompleteMultiple = "{{count}} files are ready."
property = "Property"
quickPosition = "Quick Position"
red = "Red"
remove = "Remove"
reset = "Reset"
review = "Review"
save = "Save"
@@ -180,6 +182,7 @@ addMoreFiles = "Add more files..."
attachments = "Select Attachments"
info = "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel."
placeholder = "Choose files..."
removeFile = "Remove file"
selectedFiles = "Selected Files"
submit = "Add Attachments"
@@ -1628,6 +1631,9 @@ title = "Do you want to help make Stirling PDF better?"
tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand"
[annotation]
alignCenter = "Align center"
alignLeft = "Align left"
alignRight = "Align right"
annotationStyle = "Annotation style"
backgroundColor = "Background color"
borderOff = "Border: Off"
@@ -2531,6 +2537,7 @@ title = "Rule of thumb"
[certSign.source]
device = "This device"
noOtherSources = "No other certificate sources are available."
server = "Server"
stepTitle = "Certificate source"
upload = "Upload"
@@ -3595,7 +3602,9 @@ makeCopy = "Make a copy"
mobileShort = "Mobile"
mobileUpload = "Mobile Upload"
mobileUploadNotAvailable = "Mobile upload not enabled"
moreOptions = "More options"
myFiles = "My Files"
nextFile = "Next file"
noFiles = "No files available"
noFilesFound = "No files found matching your search"
noRecentFiles = "No recent files found"
@@ -3605,6 +3614,7 @@ openInFileEditor = "Open in File Editor"
openInPageEditor = "Open in Page Editor"
owner = "Owner"
ownerUnknown = "Unknown"
previousFile = "Previous file"
recent = "Recent"
removeBoth = "Remove from both"
removeFilePrompt = "This file is saved on this device and on your server. Where would you like to remove it from?"
@@ -4614,10 +4624,10 @@ welcomeTitle = "You've been invited!"
[landing]
addFiles = "Add Files"
heroSubtitle = "Drop in or add an existing PDF to get started."
heroTitle = "Stirling PDF"
mobileUpload = "Upload from Mobile"
openFromComputer = "Open from computer"
uploadFromComputer = "Upload from computer"
workbenchEmptyStateHero = "Drop a PDF anywhere"
[language]
direction = "ltr"
@@ -4930,6 +4940,7 @@ title = "Output"
[onboarding]
activeFiles = "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."
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."
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."
@@ -4955,6 +4966,7 @@ skipTheTour = "Skip the tour"
[onboarding.desktopInstall]
body = "Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer."
selectOs = "Select operating system"
title = "Download"
titleWithOs = "Download for {{osLabel}}"
@@ -5566,7 +5578,9 @@ viewLabel = "PDF Editor"
[pdfTextEditor.actions]
applyChanges = "Apply Changes"
clearText = "Clear text"
downloadCopy = "Download Copy"
moreOptions = "More options"
reset = "Reset Changes"
[pdfTextEditor.badges]
@@ -8918,6 +8932,21 @@ manage = "Manage"
description = "Policies and legal information for this service."
title = "Legal Documents"
[settings.licenses]
backendDescription = "Licenses for backend dependencies bundled with this server."
backendLabel = "Backend Licenses"
backendTitle = "Backend 3rd Party Licenses"
empty = "No dependencies found."
frontendDescription = "Licenses for frontend dependencies bundled into the release build."
frontendLabel = "Frontend Licenses"
frontendTitle = "Frontend 3rd Party Licenses"
license = "License"
listDescription = "The list is shown directly in the UI from the release bundle or backend endpoint."
listTitle = "Bundled dependencies"
loadError = "Failed to load third-party licenses"
module = "Module"
version = "Version"
[settings.licensingAnalytics]
audit = "Audit"
plan = "Plan"
@@ -9670,7 +9699,9 @@ memberRemoved = "Member removed successfully"
namePlaceholder = "Enter team name"
personal = "Personal"
removeError = "Failed to remove member"
renameCancel = "Cancel rename"
renameError = "Failed to rename team"
renameSubmit = "Save team name"
renameSuccess = "Team renamed successfully"
[team.invitationBanner]
@@ -9686,6 +9717,7 @@ sendButton = "Send Invitation"
title = "Invite Team Member"
[team.members]
actions = "Member actions"
emailColumn = "Email"
empty = "No team members yet"
nameColumn = "Name"
@@ -10041,14 +10073,23 @@ zoomOut = "Zoom Out"
[viewer.attachments]
addAttachment = "Add attachment"
close = "Close attachments"
closeSidebar = "Close attachments sidebar"
download = "Download attachment"
empty = "No attachments in this document"
loading = "Loading attachments..."
noDocument = "Open a PDF to view its attachments."
noMatch = "No attachments match your search"
noSupport = "Attachment support is unavailable for this viewer."
retry = "Retry"
searchPlaceholder = "Search attachments"
title = "Attachments"
[viewer.bookmarks]
bookmarkTitle = "Bookmark title"
closeSidebar = "Close bookmarks sidebar"
collapseAll = "Collapse all bookmarks"
expandAll = "Expand all bookmarks"
[viewer.comments]
addComment = "Add comment"
addCommentPlaceholder = "Add comment..."
@@ -10059,6 +10100,7 @@ clearAll = "Clear all comments"
clearAllDescription = "This removes comments and replies from the sidebar while keeping any attached annotations in the document."
clearAllTitle = "Clear all comments?"
close = "Close comments"
closeSidebar = "Close comments sidebar"
deleteAnnotationAndComment = "Delete annotation & comment"
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
deleteTitle = "Remove annotation from comments?"
@@ -10090,6 +10132,11 @@ title = "Form Fields"
unsavedBadge = "Unsaved"
unsavedDesc = "You have unsaved changes"
[viewer.layers]
closeSidebar = "Close layers sidebar"
hideAll = "Hide all layers"
showAll = "Show all layers"
[viewer.link]
delete = "Delete link"
@@ -10120,6 +10167,9 @@ resultsOf = "of {{total}}"
[viewer.signature]
delete = "Delete signature"
[viewer.thumbnails]
closeSidebar = "Close thumbnails sidebar"
[viewPdf]
tags = "view,read,annotate,text,image,highlight,edit"
title = "View/Edit PDF"
@@ -10546,6 +10596,7 @@ loading = "Loading people..."
locked = "locked"
lockedBadge = "Locked"
loginRequired = "Enable login mode first"
memberActions = "Member actions"
noMembersFound = "No members found"
role = "Role"
searchMembers = "Search members..."
@@ -10555,6 +10606,7 @@ unlockAccount = "Unlock Account"
unlockUserError = "Failed to unlock user account"
unlockUserSuccess = "User account unlocked successfully"
user = "User"
userInfo = "User info"
[workspace.people.actions]
upgrade = "Upgrade"
@@ -10700,6 +10752,7 @@ removeMemberError = "Failed to remove user from team"
removeMemberSuccess = "User removed from team"
renameTeamLabel = "Rename Team"
system = "System"
teamActions = "Team actions"
teamName = "Team Name"
teamNotFound = "Team not found"
title = "Teams"
+12
View File
@@ -485,6 +485,16 @@
"title": "Legal Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/backendThirdPartyLicenses": {
"image": "/og_images/home.png",
"title": "Backend Third Party Licenses Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/frontendThirdPartyLicenses": {
"image": "/og_images/home.png",
"title": "Frontend Third Party Licenses Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/payg": {
"image": "/og_images/home.png",
"title": "Payg Settings - Stirling PDF",
@@ -641,6 +651,8 @@
"/settings/adminMcp": "/settings/adminMcp",
"/settings/help": "/settings/help",
"/settings/legal": "/settings/legal",
"/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses",
"/settings/frontendThirdPartyLicenses": "/settings/frontendThirdPartyLicenses",
"/settings/payg": "/settings/payg"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

@@ -10,7 +10,8 @@ Terminal=false
MimeType=application/pdf;
Categories=Office;Graphics;Utility;
Actions=open-file;
StartupWMClass=Stirling-PDF
[Desktop Action open-file]
Name=Open PDF File
Exec={{exec}} %F
Exec={{exec}} %F
+275 -72
View File
@@ -3,133 +3,182 @@
{
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
"moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git",
"moduleVersion": "1.7.7",
"moduleVersion": "1.7.9",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
},
{
"moduleName": "@embedpdf/core",
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
"moduleVersion": "1.3.0",
"moduleName": "@cantoo/pdf-lib",
"moduleUrl": "git+https://github.com/cantoo-scribe/pdf-lib.git",
"moduleVersion": "2.6.5",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
"moduleLicenseUrl": "git+https://github.com/cantoo-scribe/pdf-lib.git"
},
{
"moduleName": "@dnd-kit/core",
"moduleUrl": "git+https://github.com/clauderic/dnd-kit.git",
"moduleVersion": "6.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/clauderic/dnd-kit.git"
},
{
"moduleName": "@embedpdf/core",
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.4.tgz",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.4.tgz"
},
{
"moduleName": "@embedpdf/engines",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/models",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-annotation",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-attachment",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-bookmark",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-document-manager",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-export",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-history",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-interaction-manager",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-loader",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-pan",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-print",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-redaction",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-render",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-rotate",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-scroll",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-search",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-selection",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-spread",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-thumbnail",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-tiling",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-viewport",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-zoom",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
@@ -157,94 +206,185 @@
{
"moduleName": "@mantine/core",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleVersion": "8.3.18",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dates",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleVersion": "8.3.18",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dropzone",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleVersion": "8.3.18",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/hooks",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleVersion": "8.3.18",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mui/icons-material",
"moduleUrl": "git+https://github.com/mui/material-ui.git",
"moduleVersion": "7.3.2",
"moduleVersion": "9.0.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
},
{
"moduleName": "@mui/material",
"moduleUrl": "git+https://github.com/mui/material-ui.git",
"moduleVersion": "7.3.2",
"moduleVersion": "9.0.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
},
{
"moduleName": "@tailwindcss/postcss",
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
"moduleVersion": "4.1.13",
"moduleName": "@posthog/react",
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
"moduleVersion": "1.8.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
},
{
"moduleName": "@reactour/tour",
"moduleUrl": "git+https://github.com/elrumordelaluz/reactour.git",
"moduleVersion": "3.8.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/elrumordelaluz/reactour.git"
},
{
"moduleName": "@stripe/react-stripe-js",
"moduleUrl": "https://github.com/stripe/react-stripe-js.git",
"moduleVersion": "4.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/stripe/react-stripe-js.git"
},
{
"moduleName": "@stripe/stripe-js",
"moduleUrl": "https://github.com/stripe/stripe-js.git",
"moduleVersion": "7.9.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/stripe/stripe-js.git"
},
{
"moduleName": "@supabase/supabase-js",
"moduleUrl": "https://github.com/supabase/supabase-js.git",
"moduleVersion": "2.100.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/supabase/supabase-js.git"
},
{
"moduleName": "@tailwindcss/postcss",
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss.git",
"moduleVersion": "4.2.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/tailwindlabs/tailwindcss.git"
},
{
"moduleName": "@tanstack/react-virtual",
"moduleUrl": "git+https://github.com/TanStack/virtual.git",
"moduleVersion": "3.13.12",
"moduleVersion": "3.13.23",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git"
},
{
"moduleName": "@tauri-apps/api",
"moduleUrl": "git+https://github.com/tauri-apps/tauri.git",
"moduleVersion": "2.10.1",
"moduleLicense": "Apache-2.0 OR MIT",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/tauri.git"
},
{
"moduleName": "@tauri-apps/plugin-dialog",
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
"moduleVersion": "2.7.0",
"moduleLicense": "MIT OR Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
},
{
"moduleName": "@tauri-apps/plugin-fs",
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
"moduleVersion": "2.5.0",
"moduleLicense": "MIT OR Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
},
{
"moduleName": "@tauri-apps/plugin-http",
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
"moduleVersion": "2.5.7",
"moduleLicense": "MIT OR Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
},
{
"moduleName": "@tauri-apps/plugin-notification",
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
"moduleVersion": "2.3.3",
"moduleLicense": "MIT OR Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
},
{
"moduleName": "@tauri-apps/plugin-shell",
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
"moduleVersion": "2.3.5",
"moduleLicense": "MIT OR Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
},
{
"moduleName": "@userback/widget",
"moduleUrl": "git+https://github.com/userback/widget-js.git",
"moduleVersion": "0.3.12",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/userback/widget-js.git"
},
{
"moduleName": "autoprefixer",
"moduleUrl": "git+https://github.com/postcss/autoprefixer.git",
"moduleVersion": "10.4.21",
"moduleVersion": "10.4.27",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git"
},
{
"moduleName": "axios",
"moduleUrl": "git+https://github.com/axios/axios.git",
"moduleVersion": "1.12.2",
"moduleVersion": "1.15.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/axios/axios.git"
},
{
"moduleName": "d3",
"moduleUrl": "git+https://github.com/d3/d3.git",
"moduleVersion": "7.9.0",
"moduleLicense": "ISC",
"moduleLicenseUrl": "git+https://github.com/d3/d3.git"
},
{
"moduleName": "globals",
"moduleUrl": "git+https://github.com/sindresorhus/globals.git",
"moduleVersion": "17.5.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/sindresorhus/globals.git"
},
{
"moduleName": "i18next",
"moduleUrl": "git+https://github.com/i18next/i18next.git",
"moduleVersion": "25.5.2",
"moduleVersion": "25.10.10",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/i18next.git"
},
{
"moduleName": "i18next-browser-languagedetector",
"moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git",
"moduleVersion": "8.2.0",
"moduleVersion": "8.2.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git"
},
{
"moduleName": "i18next-http-backend",
"moduleUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git",
"moduleVersion": "3.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git"
},
{
"moduleName": "jszip",
"moduleUrl": "git+https://github.com/Stuk/jszip.git",
@@ -254,66 +394,129 @@
},
{
"moduleName": "license-report",
"moduleUrl": "git+https://github.com/kessler/license-report.git",
"moduleVersion": "6.8.0",
"moduleUrl": "git+https://github.com/bepo65/license-report.git",
"moduleVersion": "6.8.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/kessler/license-report.git"
},
{
"moduleName": "pdf-lib",
"moduleUrl": "git+https://github.com/Hopding/pdf-lib.git",
"moduleVersion": "1.17.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git"
"moduleLicenseUrl": "git+https://github.com/bepo65/license-report.git"
},
{
"moduleName": "pdfjs-dist",
"moduleUrl": "git+https://github.com/mozilla/pdf.js.git",
"moduleVersion": "5.4.149",
"moduleVersion": "5.5.207",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git"
},
{
"moduleName": "peerjs",
"moduleUrl": "git+https://github.com/peers/peerjs.git",
"moduleVersion": "1.5.5",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/peers/peerjs.git"
},
{
"moduleName": "pixelmatch",
"moduleUrl": "git+https://github.com/mapbox/pixelmatch.git",
"moduleVersion": "7.1.0",
"moduleLicense": "ISC",
"moduleLicenseUrl": "git+https://github.com/mapbox/pixelmatch.git"
},
{
"moduleName": "posthog-js",
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
"moduleVersion": "1.268.0",
"moduleUrl": "https://github.com/PostHog/posthog-js",
"moduleVersion": "1.363.3",
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
"moduleLicenseUrl": "https://github.com/PostHog/posthog-js"
},
{
"moduleName": "qrcode.react",
"moduleUrl": "git+https://github.com/zpao/qrcode.react.git",
"moduleVersion": "4.2.0",
"moduleLicense": "ISC",
"moduleLicenseUrl": "git+https://github.com/zpao/qrcode.react.git"
},
{
"moduleName": "react",
"moduleUrl": "git+https://github.com/facebook/react.git",
"moduleVersion": "19.1.1",
"moduleVersion": "19.2.4",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
},
{
"moduleName": "react-dom",
"moduleUrl": "git+https://github.com/facebook/react.git",
"moduleVersion": "19.1.1",
"moduleVersion": "19.2.4",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
},
{
"moduleName": "react-easy-crop",
"moduleUrl": "git+https://github.com/ValentinH/react-easy-crop.git",
"moduleVersion": "5.5.6",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/ValentinH/react-easy-crop.git"
},
{
"moduleName": "react-i18next",
"moduleUrl": "git+https://github.com/i18next/react-i18next.git",
"moduleVersion": "15.7.3",
"moduleVersion": "16.6.6",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git"
},
{
"moduleName": "react-markdown",
"moduleUrl": "git+https://github.com/remarkjs/react-markdown.git",
"moduleVersion": "9.1.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/remarkjs/react-markdown.git"
},
{
"moduleName": "react-rnd",
"moduleUrl": "git+https://github.com/bokuweb/react-rnd.git",
"moduleVersion": "10.5.3",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/bokuweb/react-rnd.git"
},
{
"moduleName": "react-router-dom",
"moduleUrl": "git+https://github.com/remix-run/react-router.git",
"moduleVersion": "7.9.1",
"moduleVersion": "7.13.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git"
},
{
"moduleName": "tailwindcss",
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
"moduleVersion": "4.1.13",
"moduleName": "recharts",
"moduleUrl": "git+https://github.com/recharts/recharts.git",
"moduleVersion": "3.8.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
"moduleLicenseUrl": "git+https://github.com/recharts/recharts.git"
},
{
"moduleName": "remark-gfm",
"moduleUrl": "git+https://github.com/remarkjs/remark-gfm.git",
"moduleVersion": "4.0.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/remarkjs/remark-gfm.git"
},
{
"moduleName": "signature_pad",
"moduleUrl": "git+https://github.com/szimek/signature_pad.git",
"moduleVersion": "5.1.3",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/szimek/signature_pad.git"
},
{
"moduleName": "smol-toml",
"moduleUrl": "github:squirrelchat/smol-toml",
"moduleVersion": "1.6.1",
"moduleLicense": "BSD-3-Clause",
"moduleLicenseUrl": "github:squirrelchat/smol-toml"
},
{
"moduleName": "tailwindcss",
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss.git",
"moduleVersion": "4.2.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/tailwindlabs/tailwindcss.git"
},
{
"moduleName": "web-vitals",
@@ -1,5 +1,7 @@
import React from "react";
import { Button, Group, ActionIcon } from "@mantine/core";
import { Group } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import { TFunction } from "i18next";
import {
@@ -31,22 +33,6 @@ export function renderButtons({
(btn) => btn.group === "right",
);
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
variant === "primary"
? {
root: {
background: "var(--onboarding-primary-button-bg)",
color: "var(--onboarding-primary-button-text)",
},
}
: {
root: {
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
};
const resolveButtonLabel = (button: ButtonDefinition) => {
// Translate the label (it's a translation key)
const label = button.label ?? "";
@@ -65,20 +51,15 @@ export function renderButtons({
<ActionIcon
key={button.key}
onClick={() => onAction(button.action)}
radius="md"
size={40}
size="lg"
variant="secondary"
accent="neutral"
disabled={disabled}
styles={{
root: {
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
}}
aria-label={t("onboarding.buttons.back", "Back")}
>
{button.icon === "chevron-left" && (
{button.icon === "chevron-left" ? (
<ChevronLeftIcon fontSize="small" />
)}
) : null}
</ActionIcon>
);
}
@@ -91,7 +72,8 @@ export function renderButtons({
key={button.key}
onClick={() => onAction(button.action)}
disabled={disabled}
styles={buttonStyles(variant)}
variant={variant === "primary" ? "primary" : "secondary"}
accent="neutral"
>
{label}
</Button>
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Badge, Button, TextInput } from "@mantine/core";
import { Badge, TextInput } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { Modal, Stack, Button } from "@mantine/core";
import { Modal, Stack } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import CelebrationIcon from "@mui/icons-material/CelebrationOutlined";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
@@ -169,7 +170,7 @@ export function FreeLimitReachedModal({ onClose }: FreeLimitReachedModalProps) {
>
<Button
onClick={onClose}
variant="default"
variant="secondary"
size="sm"
className="free-limit-modal-button"
style={{
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { Modal, Stack, Button } from "@mantine/core";
import { Modal, Stack } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import TrendingUpIcon from "@mui/icons-material/TrendingUpOutlined";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
@@ -167,7 +168,7 @@ export function SpendCapReachedModal({ onClose }: SpendCapReachedModalProps) {
>
<Button
onClick={onClose}
variant="default"
variant="secondary"
size="sm"
className="spend-cap-modal-button"
style={{
@@ -1,5 +1,6 @@
import { useState } from "react";
import { Button, Group, Text } from "@mantine/core";
import { Group, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { InfoBanner } from "@app/components/shared/InfoBanner";
@@ -75,9 +76,9 @@ export function TeamInvitationBanner() {
const actionButtons = (
<Group gap="xs" wrap="nowrap">
<Button
variant="white"
color="gray"
size="xs"
variant="secondary"
accent="neutral"
size="sm"
onClick={handleAccept}
loading={processing}
leftSection={
@@ -88,17 +89,12 @@ export function TeamInvitationBanner() {
style={{ color: "var(--mantine-color-dark-9)" }}
/>
}
styles={{
label: {
color: "var(--mantine-color-dark-9)",
},
}}
>
{t("team.invitationBanner.acceptButton", "Accept")}
</Button>
<Button
variant="subtle"
size="xs"
variant="tertiary"
size="sm"
onClick={handleReject}
loading={processing}
style={{ color: "rgba(255, 255, 255, 0.7)" }}
@@ -14,14 +14,13 @@
--payg-divider: var(--border-subtle);
}
/* Dark mode: the modal content bg is #2a2f36 and so is --bg-surface, so plain
cards vanish. Lift cards a shade above the modal and strengthen borders. */
/* Dark mode: modal content bg is #131729 (--bg-surface), so lift cards above it */
[data-mantine-color-scheme="dark"] .payg {
--payg-card-bg: #313842;
--payg-card-border: #3d444e;
--payg-inset-bg: #272c33;
--payg-accent-soft: rgba(10, 139, 255, 0.16);
--payg-divider: #3d444e;
--payg-card-bg: #1c2340;
--payg-card-border: #2d3560;
--payg-inset-bg: #0d1020;
--payg-accent-soft: rgba(79, 142, 245, 0.16);
--payg-divider: #2d3560;
}
/* ── Header ─────────────────────────────────────────────────────────── */
@@ -258,16 +257,16 @@
/* Dark mode: the stock default button is a flat slab that disappears into the
card. Lift it with a soft top-down gradient + brighter border, and warm the
hover so the buttons feel tactile against --payg-card-bg (#313842). Driven
hover so the buttons feel tactile against --payg-card-bg (#1c2340). Driven
through Mantine's --button-* vars. The same rule covers disabled buttons
(Cancel/Update cap before the form is dirty) so they read identically to the
always-enabled ones — Mantine only fades them via opacity. */
[data-mantine-color-scheme="dark"]
.payg
.mantine-Button-root[data-variant="default"] {
--button-bg: linear-gradient(180deg, #3d4651 0%, #353d48 100%);
--button-hover: linear-gradient(180deg, #475160 0%, #3d4654 100%);
--button-bd: 1px solid #4b5563;
--button-bg: linear-gradient(180deg, #232a50 0%, #1c2340 100%);
--button-hover: linear-gradient(180deg, #2d3560 0%, #232a50 100%);
--button-bd: 1px solid #2d3560;
--button-color: var(--mantine-color-white);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
@@ -16,7 +16,8 @@
* V1 — so it shows a real empty state, not fabricated rows
*/
import React, { useState } from "react";
import { Button, Group, Stack, Text } from "@mantine/core";
import { Group, Stack, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useRenderCount } from "@app/hooks/useRenderCount";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import LockIcon from "@mui/icons-material/LockOutlined";
@@ -110,16 +111,16 @@ export function DocHelp() {
const [open, setOpen] = useState(false);
return (
<div className="payg-help">
<button
type="button"
<Button
variant="tertiary"
className="payg-help__toggle"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
leftSection={<HelpOutlineIcon sx={{ fontSize: 15 }} />}
>
<HelpOutlineIcon sx={{ fontSize: 15 }} />
{t("payg.docHelp.toggle", "What counts as a PDF?")}
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
</button>
</Button>
{open && (
<div className="payg-help__panel">
<ul>
@@ -446,16 +447,16 @@ function CapReachedHelp() {
const [open, setOpen] = useState(false);
return (
<div className="payg-help">
<button
type="button"
<Button
variant="tertiary"
className="payg-help__toggle"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
leftSection={<HelpOutlineIcon sx={{ fontSize: 15 }} />}
>
<HelpOutlineIcon sx={{ fontSize: 15 }} />
{t("payg.gates.title", "What happens when the cap is reached")}
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
</button>
</Button>
{open && (
<div className="payg-help__panel">
<div className="payg-gates">
@@ -656,7 +657,7 @@ function StripePortalLink({
onClick={handleClick}
loading={loading}
rightSection={<OpenInNewIcon sx={{ fontSize: 16 }} />}
variant="light"
variant="secondary"
>
{t("payg.stripe.open", "Open billing portal")}
</Button>
@@ -217,25 +217,6 @@
border-top: 1px solid var(--payg-divider);
padding-top: 16px;
}
.paygf-cta__button {
padding: 13px 22px;
border: none;
border-radius: 11px;
background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%);
color: white;
font-weight: 600;
font-size: 0.95rem;
font-family: inherit;
cursor: pointer;
transition:
transform 120ms ease,
box-shadow 120ms ease;
white-space: nowrap;
}
.paygf-cta__button:hover {
transform: translateY(-1px);
box-shadow: 0 8px 22px -6px rgba(10, 139, 255, 0.55);
}
.paygf-cta__reassurance {
margin: 0;
font-size: 0.78rem;
@@ -25,6 +25,7 @@
*/
import React, { useState } from "react";
import { Stack } from "@mantine/core";
import { Button } from "@app/ui/Button";
import BoltIcon from "@mui/icons-material/BoltRounded";
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
import CheckIcon from "@mui/icons-material/CheckRounded";
@@ -171,14 +172,14 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
<FreeMeterPanel snap={snap} />
{isLeader ? (
<>
<button
type="button"
className="paygf-cta__button paygf-proc__cta"
<Button
fullWidth
className="paygf-proc__cta"
onClick={onTurnOn}
data-testid="turn-on-processor"
>
{t("payg.free.cta.button", "Turn on Processor →")}
</button>
</Button>
<span className="paygf-cta__reassurance paygf-proc__reassure">
{t(
"payg.free.cta.reassurance",
@@ -22,10 +22,10 @@
gap: 14px;
}
[data-mantine-color-scheme="dark"] .scc {
--scc-accent-text: #66b8ff;
--scc-accent-soft: rgba(10, 139, 255, 0.16);
--scc-chip-bg: #272d35;
--scc-chip-border: #3d444e;
--scc-accent-text: #7ab4ff;
--scc-accent-soft: rgba(79, 142, 245, 0.16);
--scc-chip-bg: #1c2340;
--scc-chip-border: #2d3560;
}
/* ── Inline row: presets · custom · no-cap · (save) ──────────────────── */
@@ -64,6 +64,7 @@
*/
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import { useAuth } from "@app/auth/UseSession";
import {
createCheckoutSession,
@@ -267,17 +268,12 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
)}
</div>
<div style={{ marginTop: 12 }}>
<button
type="button"
className="upm-btn"
data-variant="primary"
onClick={onComplete}
>
<Button onClick={onComplete}>
{t(
"payg.checkout.mock.continue",
"Continue with mock subscription",
)}
</button>
</Button>
</div>
</div>
);
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react";
import {
Button,
TextInput,
Group,
Text,
@@ -8,9 +7,10 @@ import {
Alert,
Table,
Badge,
ActionIcon,
Menu,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -233,19 +233,18 @@ const TeamSection: React.FC = () => {
}}
/>
<ActionIcon
variant="filled"
color="blue"
onClick={handleRenameSubmit}
loading={renamingTeam}
disabled={!newTeamName.trim()}
aria-label={t("team.renameSubmit", "Save team name")}
>
<LocalIcon icon="check" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
variant="tertiary"
onClick={handleCancelRename}
disabled={renamingTeam}
aria-label={t("team.renameCancel", "Cancel rename")}
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
@@ -257,7 +256,7 @@ const TeamSection: React.FC = () => {
</Text>
{isTeamLeader && !isPersonalTeam && (
<ActionIcon
variant="subtle"
variant="tertiary"
size="sm"
onClick={handleStartRename}
aria-label={t("team.editName", "Edit team name")}
@@ -285,9 +284,9 @@ const TeamSection: React.FC = () => {
</div>
{!isPersonalTeam && !isTeamLeader && !isEditingName && (
<Button
color="red"
variant="outline"
size="xs"
accent="danger"
variant="secondary"
size="sm"
onClick={handleLeaveTeam}
leftSection={
<LocalIcon icon="logout" width="1rem" height="1rem" />
@@ -449,7 +448,13 @@ const TeamSection: React.FC = () => {
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<Menu.Target>
<ActionIcon variant="subtle">
<ActionIcon
variant="tertiary"
aria-label={t(
"team.members.actions",
"Member actions",
)}
>
<LocalIcon
icon="more-vert"
width="1rem"
@@ -504,8 +509,8 @@ const TeamSection: React.FC = () => {
{isTeamLeader && !isPersonalTeam && (
<Table.Td>
<ActionIcon
variant="subtle"
color="red"
variant="tertiary"
accent="danger"
onClick={() =>
handleCancelInvitation(
invitation.invitationId,
@@ -17,10 +17,10 @@
--upm-muted: var(--text-muted);
}
[data-mantine-color-scheme="dark"] .upm {
--upm-card-bg: #313842;
--upm-border: #3d444e;
--upm-divider: #3d444e;
--upm-accent-soft: rgba(10, 139, 255, 0.14);
--upm-card-bg: #1c2340;
--upm-border: #2d3560;
--upm-divider: #2d3560;
--upm-accent-soft: rgba(79, 142, 245, 0.14);
}
/* Backdrop locks the page and centres the modal. z-index matches
@@ -101,26 +101,6 @@
color: var(--upm-text);
margin: 0;
}
.upm-header__close {
background: transparent;
border: none;
color: var(--upm-muted);
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
background 120ms ease,
color 120ms ease;
}
.upm-header__close:hover {
background: var(--upm-divider);
color: var(--upm-text);
}
/* Left cluster: optional back arrow + title. The back arrow only renders on the
checkout step (cap/confirm have no parent step to return to) — it replaces the
old footer "← Back" button. */
@@ -130,28 +110,6 @@
gap: 6px;
min-width: 0;
}
.upm-header__back {
background: transparent;
border: none;
color: var(--upm-muted);
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: -6px;
transition:
background 120ms ease,
color 120ms ease;
}
.upm-header__back:hover {
background: var(--upm-divider);
color: var(--upm-text);
}
.upm-steps {
display: flex;
align-items: center;
@@ -357,41 +315,6 @@
display: flex;
gap: 8px;
}
.upm-btn {
padding: 10px 18px;
border-radius: 10px;
border: 1.5px solid transparent;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 120ms ease;
font-family: inherit;
white-space: nowrap;
}
.upm-btn[data-variant="ghost"] {
background: transparent;
border-color: var(--upm-divider);
color: var(--upm-text);
}
.upm-btn[data-variant="ghost"]:hover {
border-color: var(--upm-accent);
color: var(--upm-accent);
}
.upm-btn[data-variant="primary"] {
background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2));
color: white;
}
.upm-btn[data-variant="primary"]:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px -6px rgba(10, 139, 255, 0.5);
}
.upm-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
/* ── Step 3: confirmation ─────────────────────────────────────────────── */
.upm-confirm {
@@ -18,6 +18,8 @@
*/
import React, { Suspense, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import CloseIcon from "@mui/icons-material/CloseRounded";
import ArrowBackIcon from "@mui/icons-material/ArrowBackRounded";
import ShieldIcon from "@mui/icons-material/ShieldOutlined";
@@ -152,14 +154,14 @@ export default function UpgradeModal({
<header className="upm-header">
<div className="upm-header__left">
{step === "checkout" && (
<button
type="button"
className="upm-header__back"
<ActionIcon
variant="tertiary"
aria-label={t("payg.upgrade.backAria", "Back")}
onClick={goBackToCap}
style={{ marginLeft: -6 }}
>
<ArrowBackIcon fontSize="small" />
</button>
</ActionIcon>
)}
<h2 className="upm-header__title">
{step === "confirm"
@@ -170,14 +172,13 @@ export default function UpgradeModal({
)}
</h2>
</div>
<button
type="button"
className="upm-header__close"
<ActionIcon
variant="tertiary"
aria-label={t("payg.upgrade.closeAria", "Close")}
onClick={closeAndReset}
>
<CloseIcon fontSize="small" />
</button>
</ActionIcon>
</header>
{/* Step indicator. Hidden on the confirmation panel since the
@@ -198,13 +199,13 @@ export default function UpgradeModal({
"{{symbol}}{{amount}} / month",
{ symbol: sym, amount: effectiveCap },
)}
<button
type="button"
<Button
variant="tertiary"
className="upm-step__edit"
onClick={goBackToCap}
>
{t("payg.upgrade.checkout.edit", "Edit")}
</button>
</Button>
</span>
) : (
<span>
@@ -264,36 +265,23 @@ export default function UpgradeModal({
<div className="upm-footer__actions">
{step === "cap" && (
<>
<button
type="button"
className="upm-btn"
data-variant="ghost"
onClick={closeAndReset}
>
<Button variant="secondary" onClick={closeAndReset}>
{t("payg.upgrade.button.cancel", "Cancel")}
</button>
<button
type="button"
className="upm-btn"
data-variant="primary"
onClick={goToCheckout}
>
</Button>
<Button onClick={goToCheckout}>
{t("payg.upgrade.button.continue", "Continue →")}
</button>
</Button>
</>
)}
{step === "confirm" && (
<button
type="button"
className="upm-btn"
data-variant="primary"
<Button
onClick={() => {
setStep("cap");
onComplete({ capUsd: effectiveCap });
}}
>
{t("payg.upgrade.button.finish", "Finish")}
</button>
</Button>
)}
</div>
</footer>
@@ -1,3 +1,3 @@
<svg width="37" height="36" viewBox="0 0 37 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5 3.3125C16.5712 3.3125 14.6613 3.6924 12.8793 4.43052C11.0974 5.16864 9.47823 6.25051 8.11437 7.61437C5.35993 10.3688 3.8125 14.1046 3.8125 18C3.8125 24.4919 8.02781 29.9997 13.8588 31.9531C14.5931 32.0706 14.8281 31.6153 14.8281 31.2187V28.7366C10.7597 29.6178 9.89312 26.7684 9.89312 26.7684C9.2175 25.0647 8.26281 24.6094 8.26281 24.6094C6.92625 23.6987 8.36562 23.7281 8.36562 23.7281C9.83437 23.8309 10.6128 25.2409 10.6128 25.2409C11.8906 27.4734 14.0497 26.8125 14.8869 26.46C15.0191 25.5053 15.4009 24.8591 15.8122 24.4919C12.5516 24.1247 9.12937 22.8616 9.12937 17.2656C9.12937 15.6353 9.6875 14.3281 10.6422 13.2853C10.4953 12.9181 9.98125 11.3906 10.7891 9.40781C10.7891 9.40781 12.0228 9.01125 14.8281 10.9059C15.9884 10.5828 17.2516 10.4212 18.5 10.4212C19.7484 10.4212 21.0116 10.5828 22.1719 10.9059C24.9772 9.01125 26.2109 9.40781 26.2109 9.40781C27.0188 11.3906 26.5047 12.9181 26.3578 13.2853C27.3125 14.3281 27.8706 15.6353 27.8706 17.2656C27.8706 22.8762 24.4338 24.11 21.1584 24.4772C21.6872 24.9325 22.1719 25.8284 22.1719 27.1944V31.2187C22.1719 31.6153 22.4069 32.0853 23.1559 31.9531C28.9869 29.985 33.1875 24.4919 33.1875 18C33.1875 16.0712 32.8076 14.1613 32.0695 12.3793C31.3314 10.5974 30.2495 8.97823 28.8856 7.61437C27.5218 6.25051 25.9026 5.16864 24.1207 4.43052C22.3387 3.6924 20.4288 3.3125 18.5 3.3125Z" fill="black"/>
<path d="M18.5 3.3125C16.5712 3.3125 14.6613 3.6924 12.8793 4.43052C11.0974 5.16864 9.47823 6.25051 8.11437 7.61437C5.35993 10.3688 3.8125 14.1046 3.8125 18C3.8125 24.4919 8.02781 29.9997 13.8588 31.9531C14.5931 32.0706 14.8281 31.6153 14.8281 31.2187V28.7366C10.7597 29.6178 9.89312 26.7684 9.89312 26.7684C9.2175 25.0647 8.26281 24.6094 8.26281 24.6094C6.92625 23.6987 8.36562 23.7281 8.36562 23.7281C9.83437 23.8309 10.6128 25.2409 10.6128 25.2409C11.8906 27.4734 14.0497 26.8125 14.8869 26.46C15.0191 25.5053 15.4009 24.8591 15.8122 24.4919C12.5516 24.1247 9.12937 22.8616 9.12937 17.2656C9.12937 15.6353 9.6875 14.3281 10.6422 13.2853C10.4953 12.9181 9.98125 11.3906 10.7891 9.40781C10.7891 9.40781 12.0228 9.01125 14.8281 10.9059C15.9884 10.5828 17.2516 10.4212 18.5 10.4212C19.7484 10.4212 21.0116 10.5828 22.1719 10.9059C24.9772 9.01125 26.2109 9.40781 26.2109 9.40781C27.0188 11.3906 26.5047 12.9181 26.3578 13.2853C27.3125 14.3281 27.8706 15.6353 27.8706 17.2656C27.8706 22.8762 24.4338 24.11 21.1584 24.4772C21.6872 24.9325 22.1719 25.8284 22.1719 27.1944V31.2187C22.1719 31.6153 22.4069 32.0853 23.1559 31.9531C28.9869 29.985 33.1875 24.4919 33.1875 18C33.1875 16.0712 32.8076 14.1613 32.0695 12.3793C31.3314 10.5974 30.2495 8.97823 28.8856 7.61437C27.5218 6.25051 25.9026 5.16864 24.1207 4.43052C22.3387 3.6924 20.4288 3.3125 18.5 3.3125Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,5 +1,6 @@
import React from "react";
import { Card, Group, Text, Button, Progress } from "@mantine/core";
import { Card, Group, Text, Progress } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import StorageIcon from "@mui/icons-material/Storage";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -58,21 +59,16 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
<Group gap="xs">
{filesCount > 0 && (
<Button
variant="light"
color="red"
size="xs"
variant="secondary"
accent="danger"
size="sm"
onClick={onClearAll}
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
>
{t("fileManager.clearAll", "Clear All")}
</Button>
)}
<Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
<Button variant="secondary" size="sm" onClick={onReloadFiles}>
Reload Files
</Button>
</Group>
@@ -1,5 +1,4 @@
import {
ActionIcon,
Tooltip,
Popover,
Stack,
@@ -10,6 +9,7 @@ import {
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import ColorizeIcon from "@mui/icons-material/Colorize";
import { ActionIcon } from "@app/ui/ActionIcon";
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
// the button is hidden in the UI if the API is not supported.
@@ -66,24 +66,12 @@ export function ColorControl({
<Popover.Target>
<Tooltip label={label}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={label}
variant="secondary"
accent="neutral"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
>
<ColorSwatch color={localColor} size={18} />
</ActionIcon>
@@ -117,11 +105,14 @@ export function ColorControl({
label={t("color.eyeDropper.tooltip", "Pick colour from screen")}
>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t(
"color.eyeDropper.tooltip",
"Pick colour from screen",
)}
variant="tertiary"
accent="neutral"
size="sm"
onClick={handleEyeDropper}
style={{ color: "var(--text-primary)" }}
>
<ColorizeIcon style={{ fontSize: 16 }} />
</ActionIcon>
@@ -4,13 +4,12 @@ import {
Stack,
ColorPicker as MantineColorPicker,
Group,
Button,
ColorSwatch,
Slider,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
interface ColorPickerProps {
isOpen: boolean;
onClose: () => void;
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { Paper, Button, Modal, Stack, Text, Group } from "@mantine/core";
import { Paper, Modal, Stack, Text, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import { ColorSwatchButton } from "@app/components/annotation/shared/ColorPicker";
import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector";
import SignaturePad from "signature_pad";
@@ -331,7 +332,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
</PrivateContent>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<Button variant="subtle" color="red" onClick={clear}>
<Button variant="tertiary" accent="danger" onClick={clear}>
{t("sign.canvas.clear", "Clear canvas")}
</Button>
<Button onClick={closeModal}>{t("common.done", "Done")}</Button>
@@ -1,7 +1,9 @@
import React from "react";
import { Group, Button, ActionIcon, Tooltip } from "@mantine/core";
import { Group, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
interface DrawingControlsProps {
onUndo?: () => void;
@@ -37,12 +39,11 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
{onUndo && (
<Tooltip label={t("sign.undo", "Undo")}>
<ActionIcon
variant="subtle"
variant="tertiary"
size="lg"
aria-label={t("sign.undo", "Undo")}
onClick={onUndo}
disabled={undoDisabled}
color={undoDisabled ? "gray" : "blue"}
>
<LocalIcon
icon="undo"
@@ -56,12 +57,11 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
{onRedo && (
<Tooltip label={t("sign.redo", "Redo")}>
<ActionIcon
variant="subtle"
variant="tertiary"
size="lg"
aria-label={t("sign.redo", "Redo")}
onClick={onRedo}
disabled={redoDisabled}
color={redoDisabled ? "gray" : "blue"}
>
<LocalIcon
icon="redo"
@@ -78,11 +78,9 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
<Button
variant="filled"
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
ml="auto"
style={{ marginLeft: "auto" }}
>
{placeButtonText}
</Button>
@@ -1,14 +1,8 @@
import {
ActionIcon,
Tooltip,
Popover,
Stack,
Slider,
Text,
} from "@mantine/core";
import { Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import OpacityIcon from "@mui/icons-material/Opacity";
import { ActionIcon } from "@app/ui/ActionIcon";
interface OpacityControlProps {
value: number; // 0-100
@@ -29,24 +23,12 @@ export function OpacityControl({
<Popover.Target>
<Tooltip label={t("annotation.opacity", "Opacity")}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("annotation.opacity", "Opacity")}
variant="secondary"
accent="neutral"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
>
<OpacityIcon style={{ fontSize: 18 }} />
</ActionIcon>
@@ -1,14 +1,7 @@
import {
ActionIcon,
Tooltip,
Popover,
Stack,
Slider,
Text,
Group,
Button,
} from "@mantine/core";
import { Tooltip, Popover, Stack, Slider, Text, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useState } from "react";
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
import type { PdfAnnotationObject } from "@embedpdf/models";
@@ -106,21 +99,24 @@ export function PropertiesPopover({
</Text>
<Group gap="xs">
<ActionIcon
variant={currentAlign === "left" ? "filled" : "default"}
aria-label={t("annotation.alignLeft", "Align left")}
variant={currentAlign === "left" ? "primary" : "secondary"}
onClick={() => onUpdate({ textAlign: 0 })}
size="md"
>
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === "center" ? "filled" : "default"}
aria-label={t("annotation.alignCenter", "Align center")}
variant={currentAlign === "center" ? "primary" : "secondary"}
onClick={() => onUpdate({ textAlign: 1 })}
size="md"
>
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === "right" ? "filled" : "default"}
aria-label={t("annotation.alignRight", "Align right")}
variant={currentAlign === "right" ? "primary" : "secondary"}
onClick={() => onUpdate({ textAlign: 2 })}
size="md"
>
@@ -176,8 +172,8 @@ export function PropertiesPopover({
/>
</div>
<Button
size="xs"
variant={!borderVisible ? "filled" : "light"}
size="sm"
variant={!borderVisible ? "primary" : "secondary"}
onClick={() => {
const newValue = borderVisible ? 0 : 1;
onUpdate({
@@ -201,24 +197,12 @@ export function PropertiesPopover({
<Popover.Target>
<Tooltip label={t("annotation.properties", "Properties")}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("annotation.properties", "Properties")}
variant="secondary"
accent="neutral"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
>
<TuneIcon style={{ fontSize: 18 }} />
</ActionIcon>
@@ -7,8 +7,8 @@ import {
useCombobox,
Group,
Box,
SegmentedControl,
} from "@mantine/core";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
@@ -253,14 +253,14 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{/* Text Alignment */}
{onTextAlignChange && (
<SegmentedControl
<SegmentedControl<"left" | "center" | "right">
value={textAlign}
onChange={(value: string) => {
onTextAlignChange(value as "left" | "center" | "right");
onChange={(value) => {
onTextAlignChange(value);
onAnyChange?.();
}}
disabled={disabled}
data={[
loading={disabled}
options={[
{ label: t("textAlign.left", "Left"), value: "left" },
{ label: t("textAlign.center", "Center"), value: "center" },
{ label: t("textAlign.right", "Right"), value: "right" },
@@ -1,14 +1,8 @@
import {
ActionIcon,
Tooltip,
Popover,
Stack,
Slider,
Text,
} from "@mantine/core";
import { Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import LineWeightIcon from "@mui/icons-material/LineWeight";
import { ActionIcon } from "@app/ui/ActionIcon";
interface WidthControlProps {
value: number;
@@ -33,24 +27,12 @@ export function WidthControl({
<Popover.Target>
<Tooltip label={t("annotation.width", "Width")}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("annotation.width", "Width")}
variant="secondary"
accent="neutral"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
>
<LineWeightIcon style={{ fontSize: 18 }} />
</ActionIcon>
@@ -1,5 +1,6 @@
import React, { useRef, useState } from "react";
import { Button, Group } from "@mantine/core";
import { Group } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -106,6 +107,7 @@ const AddFileCard = ({
>
{!isUploadHover && (
<Button
variant="tertiary"
style={{
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
@@ -133,6 +135,7 @@ const AddFileCard = ({
</Button>
)}
<Button
variant="tertiary"
aria-label={t("addFileCard.upload", "Upload")}
title={terminology.uploadFromComputer}
style={{
@@ -291,15 +291,15 @@
DARK MODE OVERRIDES
========================= */
:global([data-mantine-color-scheme="dark"]) .card {
outline-color: #3a4047; /* deselected stroke */
outline-color: #1c2340; /* deselected stroke */
}
:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] {
outline-color: #4b525a; /* selected stroke (subtle grey) */
outline-color: #2d3560; /* selected stroke (subtle navy) */
}
:global([data-mantine-color-scheme="dark"]) .headerResting {
background: #1f2329; /* requested default unselected color */
background: #0d1020; /* requested default unselected color */
color: var(--tool-header-text); /* #D0D6DC */
border-bottom-color: var(--tool-header-border); /* #3A4047 */
}
@@ -1,13 +1,7 @@
import React, { useState, useCallback, useRef, useMemo } from "react";
import {
Text,
Modal,
Button,
Group,
Stack,
ActionIcon,
Tooltip,
} from "@mantine/core";
import { Text, Modal, Group, Stack, Tooltip } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Button } from "@app/ui/Button";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { alert } from "@app/components/toast";
import { useTranslation } from "react-i18next";
@@ -545,9 +539,12 @@ const FileEditorThumbnail = ({
)}
>
<ActionIcon
size="xs"
variant="filled"
color="yellow"
size="sm"
accent="warning"
aria-label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
@@ -606,17 +603,13 @@ const FileEditorThumbnail = ({
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
<Button variant="secondary" onClick={handleCancelClose}>
{t("confirmCloseCancel", "Cancel")}
</Button>
<Button
variant="filled"
color="red"
onClick={handleConfirmClose}
>
<Button accent="danger" onClick={handleConfirmClose}>
{t("confirmCloseDiscard", "Discard changes and close")}
</Button>
<Button variant="filled" onClick={handleSaveAndClose}>
<Button onClick={handleSaveAndClose}>
{t("confirmCloseSave", "Save and close")}
</Button>
</Group>
@@ -633,14 +626,10 @@ const FileEditorThumbnail = ({
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
<Button variant="secondary" onClick={handleCancelClose}>
{t("confirmCloseCancel", "Cancel")}
</Button>
<Button
variant="filled"
color="red"
onClick={handleConfirmClose}
>
<Button accent="danger" onClick={handleConfirmClose}>
{t("confirmCloseConfirm", "Close File")}
</Button>
</Group>
@@ -1,5 +1,7 @@
import React from "react";
import { Stack, Box, Text, Button, ActionIcon, Center } from "@mantine/core";
import { Stack, Box, Text, Center } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
@@ -128,18 +130,20 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{hasMultipleFiles && (
<Box style={{ display: "flex", gap: "0.25rem" }}>
<ActionIcon
variant="subtle"
variant="tertiary"
size="sm"
onClick={onPrevious}
disabled={isAnimating}
aria-label={t("fileManager.previousFile", "Previous file")}
>
<ChevronLeftIcon style={{ fontSize: 16 }} />
</ActionIcon>
<ActionIcon
variant="subtle"
variant="tertiary"
size="sm"
onClick={onNext}
disabled={isAnimating}
aria-label={t("fileManager.nextFile", "Next file")}
>
<ChevronRightIcon style={{ fontSize: 16 }} />
</ActionIcon>
@@ -150,16 +154,10 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* Action Button */}
<Button
size="sm"
accent="neutral"
onClick={onOpenFiles}
disabled={!hasSelection && !canCloseAll}
fullWidth
style={{
backgroundColor:
hasSelection || canCloseAll
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
color: "white",
}}
>
{canCloseAll
? t("fileManager.closeAllFiles", "Close all files")
@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { Button, Group, Text, Stack } from "@mantine/core";
import { Group, Text, Stack } from "@mantine/core";
import { Button } from "@app/ui/Button";
import HistoryIcon from "@mui/icons-material/History";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
@@ -77,6 +78,7 @@ const EmptyFilesState: React.FC = () => {
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
variant="tertiary"
aria-label={t("emptyFilesState.upload", "Upload")}
style={{
backgroundColor: "var(--bg-file-manager)",
@@ -1,11 +1,7 @@
import React, { useEffect } from "react";
import {
Group,
Text,
ActionIcon,
Tooltip,
SegmentedControl,
} from "@mantine/core";
import { Group, Text, Tooltip } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import SelectAllIcon from "@mui/icons-material/SelectAll";
import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
@@ -131,26 +127,29 @@ const FileActions: React.FC = () => {
}
>
<ActionIcon
variant="light"
variant="secondary"
size="sm"
color="dimmed"
onClick={handleSelectAll}
disabled={filteredFiles.length === 0}
radius="sm"
aria-label={
allFilesSelected
? t("fileManager.deselectAll", "Deselect All")
: t("fileManager.selectAll", "Select All")
}
>
<SelectAllIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
{showStorageFilter && (
<SegmentedControl
size="xs"
size="sm"
value={storageFilter}
onChange={(value) =>
onStorageFilterChange(
value as "all" | "local" | "sharedWithMe" | "sharedByMe",
)
}
data={storageFilterOptions}
options={storageFilterOptions}
/>
)}
</Group>
@@ -177,12 +176,11 @@ const FileActions: React.FC = () => {
{uploadEnabled && (
<Tooltip label={t("fileManager.uploadSelected", "Upload Selected")}>
<ActionIcon
variant="light"
variant="secondary"
size="sm"
color="dimmed"
onClick={() => setShowBulkUploadModal(true)}
disabled={!canBulkUpload}
radius="sm"
aria-label={t("fileManager.uploadSelected", "Upload Selected")}
>
<CloudUploadIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
@@ -191,12 +189,11 @@ const FileActions: React.FC = () => {
{shareLinksEnabled && (
<Tooltip label={t("fileManager.shareSelected", "Share Selected")}>
<ActionIcon
variant="light"
variant="secondary"
size="sm"
color="dimmed"
onClick={() => setShowBulkShareModal(true)}
disabled={!canBulkShare}
radius="sm"
aria-label={t("fileManager.shareSelected", "Share Selected")}
>
<LinkIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
@@ -204,12 +201,12 @@ const FileActions: React.FC = () => {
)}
<Tooltip label={t("fileManager.deleteSelected", "Delete Selected")}>
<ActionIcon
variant="light"
variant="secondary"
size="sm"
color="dimmed"
accent="danger"
onClick={handleDeleteSelected}
disabled={!hasSelection}
radius="sm"
aria-label={t("fileManager.deleteSelected", "Delete Selected")}
>
<DeleteIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
@@ -217,12 +214,11 @@ const FileActions: React.FC = () => {
<Tooltip label={terminology.downloadSelected}>
<ActionIcon
variant="light"
variant="secondary"
size="sm"
color="dimmed"
onClick={handleDownloadSelected}
disabled={!hasSelection || !hasDownloadAccess}
radius="sm"
aria-label={terminology.downloadSelected}
>
<DownloadIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Stack, Button, Box } from "@mantine/core";
import { Stack, Box } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
@@ -108,16 +109,10 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
<Button
size="md"
accent="neutral"
onClick={onOpenFiles}
disabled={!hasSelection && !canCloseAll}
fullWidth
style={{
backgroundColor:
hasSelection || canCloseAll
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
color: "white",
}}
>
{canCloseAll
? t("fileManager.closeAllFiles", "Close all files")
@@ -8,8 +8,8 @@ import {
Group,
Divider,
ScrollArea,
Button,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { detectFileExtension, getFileSize } from "@app/utils/fileUtils";
import { StirlingFileStub } from "@app/types/fileContext";
@@ -208,7 +208,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<Divider />
<Button
size="sm"
variant="light"
variant="secondary"
onClick={() => onMakeCopy(currentFile)}
fullWidth
>
@@ -260,7 +260,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
</Group>
<Button
size="sm"
variant="light"
variant="secondary"
onClick={() => setShowShareManageModal(true)}
fullWidth
>
@@ -3,12 +3,12 @@ import {
Group,
Box,
Text,
ActionIcon,
Checkbox,
Divider,
Menu,
Badge,
} from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
@@ -323,10 +323,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="dimmed"
variant="tertiary"
accent="neutral"
size="md"
onClick={(e) => e.stopPropagation()}
aria-label={t("fileManager.moreOptions", "More options")}
style={{
opacity: shouldShowHovered ? 1 : 0,
transform: shouldShowHovered ? "scale(1)" : "scale(0.8)",
@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { Stack, Text, Button, Group } from "@mantine/core";
import { Stack, Text, Group } from "@mantine/core";
import { Button } from "@app/ui/Button";
import HistoryIcon from "@mui/icons-material/History";
import PhonelinkIcon from "@mui/icons-material/Phonelink";
import { useTranslation } from "react-i18next";
@@ -65,86 +66,45 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
// Determine visibility of Mobile QR Scanner button
const shouldHideMobileQR =
!isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
const buttonProps = {
variant: (source: string) =>
activeSource === source ? "filled" : "subtle",
getColor: (source: string) =>
activeSource === source ? "var(--mantine-color-gray-2)" : undefined,
getStyles: (source: string) => ({
root: {
backgroundColor: activeSource === source ? undefined : "transparent",
color:
activeSource === source
? "var(--mantine-color-gray-9)"
: "var(--mantine-color-gray-6)",
border: "none",
"&:hover": {
backgroundColor:
activeSource === source ? undefined : "var(--mantine-color-gray-0)",
},
},
}),
};
// Shared Button has no `xs`; map the old horizontal `xs` to `sm`.
const buttonSize = "sm" as const;
const buttonJustify = horizontal ? "center" : "start";
const buttons = (
<>
<Button
variant={activeSource === "recent" ? "primary" : "tertiary"}
accent="neutral"
leftSection={<HistoryIcon />}
justify={horizontal ? "center" : "flex-start"}
justify={buttonJustify}
onClick={() => onSourceChange("recent")}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
color={buttonProps.getColor("recent")}
styles={buttonProps.getStyles("recent")}
size={buttonSize}
>
{horizontal
? t("fileManager.recent", "Recent")
: t("fileManager.recent", "Recent")}
{t("fileManager.recent", "Recent")}
</Button>
<Button
variant="subtle"
color="var(--mantine-color-gray-6)"
variant="tertiary"
accent="neutral"
leftSection={<UploadIcon />}
justify={horizontal ? "center" : "flex-start"}
justify={buttonJustify}
onClick={onLocalFileClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
styles={{
root: {
backgroundColor: "transparent",
border: "none",
"&:hover": {
backgroundColor: "var(--mantine-color-gray-0)",
},
},
}}
size={buttonSize}
>
{horizontal ? terminology.upload : terminology.uploadFiles}
</Button>
{!shouldHideGoogleDrive && (
<Button
variant="subtle"
color="var(--mantine-color-gray-6)"
variant="tertiary"
accent="neutral"
leftSection={<GoogleDriveIcon colored={isGoogleDriveEnabled} />}
justify={horizontal ? "center" : "flex-start"}
justify={buttonJustify}
onClick={handleGoogleDriveClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
size={buttonSize}
disabled={!isGoogleDriveEnabled}
styles={{
root: {
backgroundColor: "transparent",
border: "none",
"&:hover": {
backgroundColor: isGoogleDriveEnabled
? "var(--mantine-color-gray-0)"
: "transparent",
},
},
}}
title={
!isGoogleDriveEnabled
? t(
@@ -162,25 +122,14 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
{!shouldHideMobileQR && (
<Button
variant="subtle"
color="var(--mantine-color-gray-6)"
variant="tertiary"
accent="neutral"
leftSection={<PhonelinkIcon />}
justify={horizontal ? "center" : "flex-start"}
justify={buttonJustify}
onClick={handleMobileUploadClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
size={buttonSize}
disabled={!isMobileUploadEnabled}
styles={{
root: {
backgroundColor: "transparent",
border: "none",
"&:hover": {
backgroundColor: isMobileUploadEnabled
? "var(--mantine-color-gray-0)"
: "transparent",
},
},
}}
title={
!isMobileUploadEnabled
? t(
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Button, Group, Modal, Radio, Stack, Text } from "@mantine/core";
import { Alert, Group, Modal, Radio, Stack, Text } from "@mantine/core";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
import { Button } from "@app/ui/Button";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { DeleteScope } from "@app/services/serverStorageDelete";
@@ -164,11 +166,11 @@ export function DeleteFilesDialog({
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={submitting}>
<Button variant="secondary" onClick={onClose} disabled={submitting}>
{t("filesPage.cancel", "Cancel")}
</Button>
<Button
color="red"
accent="danger"
loading={submitting}
onClick={() => runConfirm(showChoice ? scope : fixedScope)}
>
@@ -1,16 +1,9 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Button,
Checkbox,
Group,
Modal,
Stack,
Text,
} from "@mantine/core";
import { Alert, Checkbox, Group, Modal, Stack, Text } from "@mantine/core";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
import { Button } from "@app/ui/Button";
import { FolderRecord } from "@app/types/folder";
interface DeleteFolderDialogProps {
@@ -95,11 +88,11 @@ export function DeleteFolderDialog({
</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={submitting}>
<Button variant="secondary" onClick={onClose} disabled={submitting}>
{t("filesPage.cancel", "Cancel")}
</Button>
<Button
color="red"
accent="danger"
loading={submitting}
onClick={async () => {
setSubmitting(true);
@@ -1,6 +1,8 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Badge, Button, Tooltip } from "@mantine/core";
import { Badge, Tooltip } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import CloseIcon from "@mui/icons-material/Close";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove";
@@ -143,7 +145,12 @@ export function FileDetailsPanel({
label={t("filesPage.closeDetails", "Close details")}
withinPortal
>
<ActionIcon variant="subtle" size="sm" onClick={onClose}>
<ActionIcon
variant="tertiary"
size="sm"
onClick={onClose}
aria-label={t("filesPage.closeDetails", "Close details")}
>
<CloseIcon fontSize="small" />
</ActionIcon>
</Tooltip>
@@ -181,25 +188,27 @@ export function FileDetailsPanel({
<span className="files-page-details-ext-tag">{ext}</span>
)}
{(single.versionNumber ?? 1) > 1 && (
<Badge size="sm" variant="filled" color="blue">
<Badge size="sm" color="blue">
v{single.versionNumber}
</Badge>
)}
</div>
<button
type="button"
<Button
variant="tertiary"
className="files-page-details-collapse-toggle"
onClick={() => setFieldsOpen((o) => !o)}
aria-expanded={fieldsOpen}
rightSection={
<KeyboardArrowDownIcon
className={`files-page-details-collapse-chevron${
fieldsOpen ? " is-open" : ""
}`}
fontSize="small"
/>
}
>
<span>{t("filesPage.fileInfo", "File info")}</span>
<KeyboardArrowDownIcon
className={`files-page-details-collapse-chevron${
fieldsOpen ? " is-open" : ""
}`}
fontSize="small"
/>
</button>
</Button>
{fieldsOpen && (
<div className="files-page-details-fieldlist">
<DetailField
@@ -256,7 +265,7 @@ export function FileDetailsPanel({
(compactVersions && onOpenVersionHistory ? (
<Button
leftSection={<HistoryIcon fontSize="small" />}
variant="default"
variant="secondary"
onClick={onOpenVersionHistory}
>
{t(
@@ -291,7 +300,6 @@ export function FileDetailsPanel({
<div className="files-page-details-actions">
<Button
leftSection={<OpenInNewIcon fontSize="small" />}
variant="filled"
onClick={() => onAddToWorkspace(selectedFileIds)}
>
{files.length === 1
@@ -302,7 +310,7 @@ export function FileDetailsPanel({
</Button>
<Button
leftSection={<DownloadIcon fontSize="small" />}
variant="default"
variant="secondary"
onClick={handleDownload}
loading={downloading}
>
@@ -329,14 +337,12 @@ export function FileDetailsPanel({
>
<Button
leftSection={<LinkIcon fontSize="small" />}
variant="default"
variant="secondary"
disabled={!sharingEnabled}
onClick={() => setShareModalOpen(true)}
styles={{
root: {
// Keep tooltip hoverable while button is disabled.
pointerEvents: sharingEnabled ? undefined : "auto",
},
style={{
// Keep tooltip hoverable while button is disabled.
pointerEvents: sharingEnabled ? undefined : "auto",
}}
>
{t("filesPage.shareManage", "Manage sharing")}
@@ -345,7 +351,7 @@ export function FileDetailsPanel({
)}
<Button
leftSection={<DriveFileMoveIcon fontSize="small" />}
variant="default"
variant="secondary"
onClick={() => onMove(selectedFileIds)}
>
{t("filesPage.moveTo", "Move to…")}
@@ -363,16 +369,12 @@ export function FileDetailsPanel({
>
<Button
leftSection={<CloudUploadIcon fontSize="small" />}
variant="default"
variant="secondary"
disabled={Boolean(saveToServerDisabledReason)}
onClick={() => onSaveToServer(localOnlyFiles)}
styles={{
root: {
// Keep tooltip hoverable while button is disabled.
pointerEvents: saveToServerDisabledReason
? "auto"
: undefined,
},
style={{
// Keep tooltip hoverable while button is disabled.
pointerEvents: saveToServerDisabledReason ? "auto" : undefined,
}}
>
{t("filesPage.saveToServer", "Save to server")}
@@ -381,8 +383,7 @@ export function FileDetailsPanel({
)}
<Button
leftSection={<DeleteIcon fontSize="small" />}
color="red"
variant="light"
accent="danger"
onClick={() => onRemove(selectedFileIds)}
>
{t("filesPage.remove", "Delete")}
@@ -1,6 +1,8 @@
import React, { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core";
import { Checkbox, Menu, Tooltip } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import FolderIcon from "@mui/icons-material/Folder";
@@ -297,10 +299,10 @@ function EmptyState({
<span style={{ display: "inline-flex" }}>
<Button
size="md"
variant="default"
variant="secondary"
leftSection={<CreateNewFolderIcon fontSize="small" />}
disabled
styles={{ root: { pointerEvents: "auto" } }}
style={{ pointerEvents: "auto" }}
>
{t("filesPage.empty.newFolderCta", "Create folder")}
</Button>
@@ -309,7 +311,7 @@ function EmptyState({
) : (
<Button
size="md"
variant="default"
variant="secondary"
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={onCreateFolder}
>
@@ -528,8 +530,6 @@ function FolderCard({
<Menu.Target>
<ActionIcon
ref={kebabRef}
variant="filled"
color="gray"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.folderMenu", "Folder actions")}
@@ -771,8 +771,6 @@ function FileCard({
<Menu.Target>
<ActionIcon
ref={kebabRef}
variant="filled"
color="gray"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
@@ -1151,7 +1149,7 @@ function FolderRow({
<Menu.Target>
<ActionIcon
ref={kebabRef}
variant="subtle"
variant="tertiary"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.folderMenu", "Folder actions")}
@@ -1370,7 +1368,7 @@ function FileRow({
<Menu.Target>
<ActionIcon
ref={kebabRef}
variant="subtle"
variant="tertiary"
size="sm"
onClick={(e) => e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
@@ -7,19 +7,12 @@ import React, {
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import {
ActionIcon,
Button,
Drawer,
Group,
MultiSelect,
SegmentedControl,
Select,
Tooltip,
} from "@mantine/core";
import { Drawer, Group, MultiSelect, Select, Tooltip } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useMediaQuery } from "@mantine/hooks";
import SearchIcon from "@mui/icons-material/Search";
import CloseIcon from "@mui/icons-material/Close";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import QrCode2Icon from "@mui/icons-material/QrCode2";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
@@ -910,8 +903,8 @@ export default function FileManagerView() {
withinPortal
>
<ActionIcon
variant="default"
size="md"
variant="secondary"
size="sm"
loading={refreshing}
disabled={refreshing || Boolean(signInRequiredReason)}
aria-busy={refreshing}
@@ -930,11 +923,11 @@ export default function FileManagerView() {
>
<span style={{ display: "inline-flex" }}>
<Button
variant="default"
variant="secondary"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
disabled
styles={{ root: { pointerEvents: "auto" } }}
style={{ pointerEvents: "auto" }}
>
{t("filesPage.newFolder", "New folder")}
</Button>
@@ -942,7 +935,7 @@ export default function FileManagerView() {
</Tooltip>
) : (
<Button
variant="default"
variant="secondary"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={() => openNewFolderDialog()}
@@ -966,9 +959,8 @@ export default function FileManagerView() {
withinPortal
>
<ActionIcon
size="lg"
variant="default"
radius="md"
size="sm"
variant="secondary"
onClick={() => setMobileUploadModalOpen(true)}
aria-label={t(
"filesPage.uploadFromMobile",
@@ -1012,11 +1004,11 @@ export default function FileManagerView() {
<span>{folders.error}</span>
<ActionIcon
size="sm"
variant="subtle"
variant="tertiary"
aria-label={t("filesPage.dismissError", "Dismiss")}
onClick={() => folders.setError(null)}
>
<CloseIcon fontSize="small" />
&times;
</ActionIcon>
</div>
)}
@@ -1147,8 +1139,8 @@ export default function FileManagerView() {
w={280}
>
<Button
variant="subtle"
size="xs"
variant="tertiary"
size="sm"
onClick={() => {
if (allSelected) {
setSelectedFileIds(new Set());
@@ -1210,19 +1202,17 @@ export default function FileManagerView() {
>
<Button
size="sm"
variant="default"
variant="secondary"
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={() =>
setSaveToServerTarget(localOnlySelectedStubs)
}
styles={{
root: {
// Keep the tooltip hoverable while disabled.
pointerEvents: saveToServerDisabledReason
? "auto"
: undefined,
},
style={{
// Keep the tooltip hoverable while disabled.
pointerEvents: saveToServerDisabledReason
? "auto"
: undefined,
}}
aria-label={t(
"filesPage.saveToServer",
@@ -1242,7 +1232,7 @@ export default function FileManagerView() {
>
<Button
size="sm"
variant="default"
variant="secondary"
leftSection={
<InfoOutlinedIcon fontSize="small" />
}
@@ -1259,7 +1249,7 @@ export default function FileManagerView() {
<Tooltip label={moveLabel} withinPortal>
<Button
size="sm"
variant="default"
variant="secondary"
leftSection={<DriveFileMoveIcon fontSize="small" />}
onClick={() => promptMoveFiles(selectedFiles)}
aria-label={moveLabel}
@@ -1270,8 +1260,8 @@ export default function FileManagerView() {
<Tooltip label={removeLabel} withinPortal>
<Button
size="sm"
color="red"
variant="light"
accent="danger"
variant="secondary"
leftSection={<DeleteIcon fontSize="small" />}
onClick={() => handleRemoveFiles(selectedFiles)}
aria-label={removeLabel}
@@ -1284,7 +1274,7 @@ export default function FileManagerView() {
withinPortal
>
<ActionIcon
variant="subtle"
variant="tertiary"
size="md"
onClick={() => clearSelection()}
aria-label={t(
@@ -1292,7 +1282,7 @@ export default function FileManagerView() {
"Clear selection",
)}
>
<CloseIcon fontSize="small" />
&times;
</ActionIcon>
</Tooltip>
</Group>
@@ -1388,7 +1378,7 @@ export default function FileManagerView() {
/>
<span className="files-page-toolbar-divider" aria-hidden="true" />
<SegmentedControl
size="xs"
size="sm"
value={viewMode}
onChange={(v) => {
// Mantine only emits values declared in `data[].value`, but
@@ -1401,7 +1391,7 @@ export default function FileManagerView() {
setViewMode(v as (typeof FILES_PAGE_VIEW_MODES)[number]);
}}
aria-label={t("filesPage.viewMode.label", "View mode")}
data={[
options={[
{
value: "grid",
label: (
@@ -1686,12 +1676,12 @@ const SearchField = React.forwardRef<
/>
{value && (
<ActionIcon
variant="subtle"
size="xs"
variant="tertiary"
size="sm"
onClick={() => onChange("")}
aria-label={t("filesPage.clearSearch", "Clear search")}
>
<CloseIcon fontSize="small" />
&times;
</ActionIcon>
)}
</div>
@@ -1712,8 +1702,8 @@ function Breadcrumbs() {
const isLast = idx === trail.length - 1;
return (
<React.Fragment key={entry.id ?? "root"}>
<button
type="button"
<Button
variant="tertiary"
className={`files-page-breadcrumb${isLast ? " is-current" : ""}`}
onClick={() => folders.setCurrentFolderId(entry.id)}
onDragOver={(e) => {
@@ -1772,7 +1762,7 @@ function Breadcrumbs() {
}}
>
{entry.name}
</button>
</Button>
{!isLast && (
<KeyboardArrowRightIcon
className="files-page-breadcrumb-sep"
@@ -13,6 +13,7 @@ import {
FOLDER_ICONS,
FolderIconOption,
} from "@app/components/filesPage/folderIcons";
import { Button } from "@app/ui/Button";
interface FolderAppearancePickerProps {
folder: FolderRecord;
@@ -50,9 +51,9 @@ export function FolderAppearancePicker({
}}
>
{FOLDER_COLOR_PALETTE.map((c) => (
<button
<Button
key={c}
type="button"
variant="secondary"
disabled={disabled}
aria-label={t(
"filesPage.appearance.useColour",
@@ -146,8 +147,8 @@ function IconButton({
}) {
return (
<Tooltip label={icon.label} withinPortal>
<button
type="button"
<Button
variant="tertiary"
disabled={disabled}
aria-label={icon.label}
onClick={(e) => {
@@ -172,7 +173,7 @@ function IconButton({
}}
>
{icon.glyph || "-"}
</button>
</Button>
</Tooltip>
);
}
@@ -1,8 +1,10 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Button, Group, Modal, Stack, TextInput } from "@mantine/core";
import { Alert, Group, Modal, Stack, TextInput } from "@mantine/core";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
import { Button } from "@app/ui/Button";
interface FolderNameDialogProps {
opened: boolean;
title: string;
@@ -94,7 +96,7 @@ export function FolderNameDialog({
</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
<Button variant="secondary" onClick={onClose}>
{t("filesPage.folderName.cancel", "Cancel")}
</Button>
<Button
@@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Menu } from "@mantine/core";
import { Menu } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import HomeIcon from "@mui/icons-material/Home";
@@ -409,8 +410,8 @@ function TreeNodeRow({
>
<Menu.Target>
<ActionIcon
size="xs"
variant="subtle"
size="sm"
variant="tertiary"
className="files-page-tree-kebab"
aria-label={t(
"filesPage.treeMenu.actions",
@@ -1,9 +1,7 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActionIcon,
Alert,
Button,
Group,
Modal,
Stack,
@@ -15,9 +13,10 @@ import HomeIcon from "@mui/icons-material/Home";
import FolderIcon from "@mui/icons-material/Folder";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import CloseIcon from "@mui/icons-material/Close";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
interface MoveToFolderDialogProps {
@@ -228,8 +227,7 @@ export function MoveToFolderDialog({
withinPortal
>
<ActionIcon
variant="subtle"
color="gray"
variant="tertiary"
size="lg"
onClick={handleCancel}
disabled={creating}
@@ -238,20 +236,20 @@ export function MoveToFolderDialog({
"Discard",
)}
>
<CloseIcon fontSize="small" />
&times;
</ActionIcon>
</Tooltip>
</Group>
) : (
<Button
variant="subtle"
variant="tertiary"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={() => {
setCreatingFolder(true);
setNewFolderName("");
}}
styles={{ root: { alignSelf: "flex-start" } }}
style={{ alignSelf: "flex-start" }}
data-testid="move-dialog-create-folder-toggle"
>
{t(
@@ -272,7 +270,7 @@ export function MoveToFolderDialog({
</Alert>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={submitting}>
<Button variant="secondary" onClick={onClose} disabled={submitting}>
{t("filesPage.moveDialog.cancel", "Cancel")}
</Button>
<Button
@@ -325,35 +323,35 @@ function FolderPick({
onPick,
}: FolderPickProps) {
return (
<button
type="button"
<Button
variant="tertiary"
justify="start"
fullWidth
onClick={onPick}
disabled={disabled}
leftSection={
isRoot ? (
<HomeIcon fontSize="small" />
) : isActive ? (
<FolderOpenIcon fontSize="small" style={{ color }} />
) : (
<FolderIcon fontSize="small" style={{ color }} />
)
}
style={{
all: "unset",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.45 : 1,
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: `0.4rem 0.75rem 0.4rem ${0.75 + depth * 0.85}rem`,
width: "100%",
background: isActive ? "var(--hover-bg)" : "transparent",
borderBottom: "1px solid var(--border-subtle)",
boxSizing: "border-box",
fontWeight: isActive ? 600 : 400,
}}
>
{isRoot ? (
<HomeIcon fontSize="small" />
) : isActive ? (
<FolderOpenIcon fontSize="small" style={{ color }} />
) : (
<FolderIcon fontSize="small" style={{ color }} />
)}
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{label}
</span>
</button>
</Button>
);
}
@@ -1,6 +1,8 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Badge, Menu } from "@mantine/core";
import { Badge, Menu } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
@@ -144,8 +146,8 @@ export function VersionTimeline({
<span className="files-page-details-version-timeline-rail-line" />
)}
</div>
<button
type="button"
<Button
variant="tertiary"
className="files-page-details-version-timeline-ellipsis-btn"
onClick={() => setShowAllCollapsed(true)}
>
@@ -154,7 +156,7 @@ export function VersionTimeline({
"Show {{count}} earlier versions",
{ count: row.hidden },
)}
</button>
</Button>
</li>
);
}
@@ -181,24 +183,31 @@ export function VersionTimeline({
)}
</div>
<div className="files-page-details-version-timeline-body">
<button
type="button"
<Button
variant="tertiary"
className="files-page-details-version-timeline-summary"
onClick={() => toggleExpand(v.id)}
aria-expanded={isExpanded}
leftSection={
<Badge
size="xs"
variant={isActive ? "filled" : "outline"}
color="blue"
>
v{v.versionNumber ?? 1}
</Badge>
}
rightSection={
<KeyboardArrowDownIcon
className={`files-page-details-version-timeline-chevron${
isExpanded ? " is-expanded" : ""
}`}
fontSize="small"
/>
}
>
<Badge
size="xs"
variant={isActive ? "filled" : "outline"}
color="blue"
>
v{v.versionNumber ?? 1}
</Badge>
{delta ? (
<span className="files-page-details-version-timeline-delta">
<span className="files-page-details-version-timeline-delta-plus">
+
</span>
<ToolLabel toolId={delta.toolId} />
</span>
) : (
@@ -206,14 +215,7 @@ export function VersionTimeline({
{t("filesPage.versionOrigin", "Original upload")}
</span>
)}
<span className="files-page-details-version-timeline-spacer" />
<KeyboardArrowDownIcon
className={`files-page-details-version-timeline-chevron${
isExpanded ? " is-expanded" : ""
}`}
fontSize="small"
/>
</button>
</Button>
<div className="files-page-details-version-timeline-meta-line">
<span>{formatFileSize(v.size)}</span>
{v.lastModified ? (
@@ -230,7 +232,7 @@ export function VersionTimeline({
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<ActionIcon
variant="subtle"
variant="tertiary"
size="sm"
aria-label={t(
"filesPage.versionActions",
@@ -303,13 +305,13 @@ export function VersionTimeline({
})}
</ol>
{collapsible && showAllCollapsed && (
<button
type="button"
<Button
variant="tertiary"
className="files-page-details-version-timeline-collapse-btn"
onClick={() => setShowAllCollapsed(false)}
>
{t("filesPage.versionCollapse", "Collapse middle versions")}
</button>
</Button>
)}
</div>
);
@@ -1,5 +1,7 @@
import React from "react";
import { Button, Group, ActionIcon } from "@mantine/core";
import { Group } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Button } from "@app/ui/Button";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import { useTranslation } from "react-i18next";
import {
@@ -33,22 +35,6 @@ export function SlideButtons({
(btn) => btn.group === "right",
);
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
variant === "primary"
? {
root: {
background: "var(--onboarding-primary-button-bg)",
color: "var(--onboarding-primary-button-text)",
},
}
: {
root: {
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
};
const resolveButtonLabel = (button: ButtonDefinition) => {
// Special case: override "See Plans" with "Upgrade now" when over limit
if (
@@ -77,20 +63,14 @@ export function SlideButtons({
<ActionIcon
key={button.key}
onClick={() => onAction(button.action)}
radius="md"
size={40}
variant="secondary"
accent="neutral"
disabled={disabled}
styles={{
root: {
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
}}
aria-label={t("onboarding.buttons.back", "Back")}
>
{button.icon === "chevron-left" && (
{button.icon === "chevron-left" ? (
<ChevronLeftIcon fontSize="small" />
)}
) : null}
</ActionIcon>
);
}
@@ -103,7 +83,10 @@ export function SlideButtons({
key={button.key}
onClick={() => onAction(button.action)}
disabled={disabled}
styles={buttonStyles(variant)}
variant={variant === "primary" ? "primary" : "secondary"}
accent={
button.accent ?? (variant === "primary" ? "default" : "neutral")
}
>
{label}
</Button>
@@ -6,9 +6,10 @@
*/
import React from "react";
import { Modal, Stack, ActionIcon } from "@mantine/core";
import { Modal, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ActionIcon } from "@app/ui/ActionIcon";
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
import CloseIcon from "@mui/icons-material/Close";
import type {
SlideDefinition,
@@ -45,6 +46,7 @@ export default function OnboardingModalSlide({
onAction,
allowDismiss = true,
}: OnboardingModalSlideProps) {
const { t } = useTranslation();
const renderHero = () => {
if (slideDefinition.hero.type === "dual-icon") {
return (
@@ -139,8 +141,9 @@ export default function OnboardingModalSlide({
{allowDismiss && (
<ActionIcon
onClick={onSkip}
radius="md"
size={36}
variant="tertiary"
size="lg"
aria-label={t("common.close", "Close")}
style={{
position: "absolute",
top: 16,
@@ -150,15 +153,12 @@ export default function OnboardingModalSlide({
backdropFilter: "blur(4px)",
zIndex: 10,
}}
styles={{
root: {
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.3)",
},
},
}}
>
<CloseIcon fontSize="small" />
<LocalIcon
icon="close-rounded"
width="1.25rem"
height="1.25rem"
/>
</ActionIcon>
)}
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
@@ -8,7 +8,7 @@
import React from "react";
import { TourProvider, useTour, type StepType } from "@reactour/tour";
import { CloseButton, ActionIcon } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import CheckIcon from "@mui/icons-material/Check";
@@ -137,7 +137,7 @@ export default function OnboardingTour({
setIsOpen,
})
}
variant="subtle"
variant="tertiary"
size="lg"
aria-label={
isLast
@@ -151,11 +151,15 @@ export default function OnboardingTour({
}}
components={{
Close: ({ onClick }) => (
<CloseButton
<ActionIcon
onClick={onClick}
variant="tertiary"
size="md"
aria-label={t("onboarding.close", "Close")}
style={{ position: "absolute", top: "8px", right: "8px" }}
/>
>
&times;
</ActionIcon>
),
Content: ({ content }: { content: string }) => (
<div
@@ -8,6 +8,7 @@ import TourOverviewSlide from "@app/components/onboarding/slides/TourOverviewSli
import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide";
import MFASetupSlide from "@app/components/onboarding/slides/MFASetupSlide";
import { SlideConfig, LicenseNotice } from "@app/types/types";
import type { ButtonAccent } from "@app/ui/Button";
export type SlideId =
| "first-login"
@@ -83,6 +84,8 @@ export interface ButtonDefinition {
label?: string;
icon?: "chevron-left";
variant?: "primary" | "secondary" | "default";
/** Accent for the shared Button; defaults to neutral. */
accent?: ButtonAccent;
group: "left" | "right";
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
@@ -238,6 +241,7 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
type: "button",
label: "onboarding.serverLicense.seePlans",
variant: "primary",
accent: "premium",
group: "right",
action: "see-plans",
},
@@ -1,6 +1,6 @@
import React from "react";
import { Trans } from "react-i18next";
import { Button } from "@mantine/core";
import { Button } from "@app/ui/Button";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import i18n from "@app/i18n";
import { SlideConfig } from "@app/types/types";
@@ -36,7 +36,7 @@ export default function AnalyticsChoiceSlide({
<br />
<div style={{ textAlign: "right", marginTop: 0 }}>
<Button
variant="default"
variant="secondary"
size="sm"
onClick={() =>
window.open(
@@ -1,6 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Menu, ActionIcon } from "@mantine/core";
import { Menu } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
export interface OSOption {
@@ -67,8 +68,12 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
<Menu position="bottom" offset={5} zIndex={10000}>
<Menu.Target>
<ActionIcon
variant="transparent"
variant="tertiary"
size="sm"
aria-label={t(
"onboarding.desktopInstall.selectOs",
"Select operating system",
)}
style={{
background: "transparent",
border: "none",
@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { Stack, PasswordInput, Button, Alert, Text } from "@mantine/core";
import { Stack, PasswordInput, Alert, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -199,8 +200,8 @@ function FirstLoginForm({
/>
<Button
type="submit"
fullWidth
type="submit"
loading={loading}
disabled={
!newPassword ||
@@ -209,7 +210,7 @@ function FirstLoginForm({
confirmPassword.length < 8
}
size="md"
mt="xs"
style={{ marginTop: "var(--mantine-spacing-xs)" }}
>
{t("firstLogin.changePassword", "Change Password")}
</Button>
@@ -8,13 +8,13 @@ import {
import {
Alert,
Box,
Button,
Group,
Loader,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { QRCodeSVG } from "qrcode.react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
@@ -210,8 +210,8 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
<Group justify="space-between" wrap="wrap">
<Button
variant="secondary"
type="button"
variant="light"
onClick={fetchMfaSetup}
disabled={mfaLoading || submitting || setupComplete}
>
@@ -226,7 +226,7 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
>
Enable MFA
</Button>
<Button type="button" variant="light" onClick={onLogout}>
<Button variant="secondary" type="button" onClick={onLogout}>
Logout
</Button>
</Group>
@@ -5,7 +5,9 @@ import React, {
useMemo,
useEffect,
} from "react";
import { ActionIcon, CheckboxIndicator } from "@mantine/core";
import { CheckboxIndicator } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
@@ -259,7 +261,7 @@ const FileThumbnail = ({
{/* Kebab menu */}
<ActionIcon
aria-label={t("moreOptions", "More options")}
variant="subtle"
variant="tertiary"
className={styles.kebab}
onClick={(e) => {
e.stopPropagation();
@@ -277,8 +279,18 @@ const FileThumbnail = ({
style={{ width: actionsWidth }}
onClick={(e) => e.stopPropagation()}
>
<button
<Button
variant="tertiary"
justify="start"
fullWidth
className={styles.actionRow}
leftSection={
isPinned ? (
<PushPinIcon fontSize="small" />
) : (
<PushPinOutlinedIcon fontSize="small" />
)
}
onClick={() => {
if (actualFile) {
if (isPinned) {
@@ -292,38 +304,36 @@ const FileThumbnail = ({
setShowActions(false);
}}
>
{isPinned ? (
<PushPinIcon fontSize="small" />
) : (
<PushPinOutlinedIcon fontSize="small" />
)}
<span>{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}</span>
</button>
<button
{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}
</Button>
<Button
variant="tertiary"
justify="start"
fullWidth
className={styles.actionRow}
leftSection={<DownloadOutlinedIcon fontSize="small" />}
onClick={() => {
downloadSelectedFile();
setShowActions(false);
}}
>
<DownloadOutlinedIcon fontSize="small" />
<span>{terminology.download}</span>
</button>
{terminology.download}
</Button>
<div className={styles.actionsDivider} />
<button
<Button
variant="tertiary"
justify="start"
fullWidth
className={`${styles.actionRow} ${styles.actionDanger}`}
leftSection={<DeleteOutlineIcon fontSize="small" />}
onClick={() => {
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
setShowActions(false);
}}
>
<DeleteOutlineIcon fontSize="small" />
<span>{t("delete", "Delete")}</span>
</button>
{t("delete", "Delete")}
</Button>
</div>
)}
@@ -1,4 +1,5 @@
import { Tooltip, ActionIcon } from "@mantine/core";
import { Tooltip } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import ContentCutIcon from "@mui/icons-material/ContentCut";
@@ -135,28 +136,22 @@ const PageEditorControls = ({
{/* Undo/Redo */}
<Tooltip label={t("pageEditor.toolbar.undo", "Undo")}>
<ActionIcon
variant="tertiary"
size="lg"
onClick={onUndo}
disabled={!canUndo}
variant="subtle"
style={{
color: canUndo ? "var(--text-secondary)" : "var(--text-muted)",
}}
radius="md"
size="lg"
aria-label={t("pageEditor.toolbar.undo", "Undo")}
>
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={t("pageEditor.toolbar.redo", "Redo")}>
<ActionIcon
variant="tertiary"
size="lg"
onClick={onRedo}
disabled={!canRedo}
variant="subtle"
style={{
color: canRedo ? "var(--text-secondary)" : "var(--text-muted)",
}}
radius="md"
size="lg"
aria-label={t("pageEditor.toolbar.redo", "Redo")}
>
<RedoIcon />
</ActionIcon>
@@ -176,17 +171,14 @@ const PageEditorControls = ({
label={t("pageEditor.toolbar.rotateLeft", "Rotate Selected Left")}
>
<ActionIcon
variant="tertiary"
size="lg"
onClick={() => onRotate("left")}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{
color:
selectedPageIds.length > 0
? "var(--text-secondary)"
: "var(--text-muted)",
}}
radius="md"
size="lg"
aria-label={t(
"pageEditor.toolbar.rotateLeft",
"Rotate Selected Left",
)}
>
<RotateLeftIcon />
</ActionIcon>
@@ -195,68 +187,47 @@ const PageEditorControls = ({
label={t("pageEditor.toolbar.rotateRight", "Rotate Selected Right")}
>
<ActionIcon
variant="tertiary"
size="lg"
onClick={() => onRotate("right")}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{
color:
selectedPageIds.length > 0
? "var(--text-secondary)"
: "var(--text-muted)",
}}
radius="md"
size="lg"
aria-label={t(
"pageEditor.toolbar.rotateRight",
"Rotate Selected Right",
)}
>
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={t("pageEditor.toolbar.delete", "Delete Selected")}>
<ActionIcon
variant="tertiary"
size="lg"
onClick={onDelete}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{
color:
selectedPageIds.length > 0
? "var(--text-secondary)"
: "var(--text-muted)",
}}
radius="md"
size="lg"
aria-label={t("pageEditor.toolbar.delete", "Delete Selected")}
>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={getSplitTooltip()}>
<ActionIcon
variant="tertiary"
size="lg"
onClick={onSplit}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{
color:
selectedPageIds.length > 0
? "var(--text-secondary)"
: "var(--text-muted)",
}}
radius="md"
size="lg"
aria-label={getSplitTooltip()}
>
<ContentCutIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={getPageBreakTooltip()}>
<ActionIcon
variant="tertiary"
size="lg"
onClick={onPageBreak}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{
color:
selectedPageIds.length > 0
? "var(--text-secondary)"
: "var(--text-muted)",
}}
radius="md"
size="lg"
aria-label={getPageBreakTooltip()}
>
<InsertPageBreakIcon />
</ActionIcon>
@@ -1,4 +1,5 @@
import { ActionIcon, Popover } from "@mantine/core";
import { Popover } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel";
@@ -37,8 +38,7 @@ export default function PageSelectByNumberButton({
<Popover.Target>
<div style={{ display: "inline-flex" }}>
<ActionIcon
variant="subtle"
radius="md"
variant="tertiary"
disabled={disabled || totalPages === 0}
aria-label={label}
>
@@ -1,4 +1,5 @@
import { Button, Text, Group, Divider } from "@mantine/core";
import { Text, Group, Divider } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
import { LogicalOperator } from "@app/utils/bulkselection/selectionBuilders";
@@ -22,7 +23,7 @@ const OperatorsSection = ({
<Group gap="sm" wrap="nowrap">
<Button
size="sm"
variant="outline"
variant="secondary"
className={classes.operatorChip}
onClick={() => onInsertOperator("and")}
disabled={!csvInput.trim()}
@@ -37,7 +38,7 @@ const OperatorsSection = ({
</Button>
<Button
size="sm"
variant="outline"
variant="secondary"
className={classes.operatorChip}
onClick={() => onInsertOperator("or")}
disabled={!csvInput.trim()}
@@ -52,7 +53,7 @@ const OperatorsSection = ({
</Button>
<Button
size="sm"
variant="outline"
variant="secondary"
className={classes.operatorChip}
onClick={() => onInsertOperator("not")}
disabled={!csvInput.trim()}
@@ -70,7 +71,7 @@ const OperatorsSection = ({
<Group gap="sm" wrap="nowrap">
<Button
size="sm"
variant="outline"
variant="secondary"
className={classes.operatorChip}
onClick={() => onInsertOperator("even")}
title={t(
@@ -84,7 +85,7 @@ const OperatorsSection = ({
</Button>
<Button
size="sm"
variant="outline"
variant="secondary"
className={classes.operatorChip}
onClick={() => onInsertOperator("odd")}
title={t(
@@ -1,4 +1,5 @@
import { TextInput, Button, Text, Flex, Switch } from "@mantine/core";
import { TextInput, Text, Flex, Switch } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
@@ -77,20 +78,15 @@ const PageSelectionInput = ({
placeholder="1,3,5-10"
rightSection={
csvInput && (
<Button
variant="subtle"
size="xs"
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={onClear}
style={{
color: "var(--text-muted)",
minWidth: "auto",
width: "24px",
height: "24px",
padding: 0,
}}
aria-label={t("clear", "Clear")}
>
×
</Button>
&times;
</ActionIcon>
)
}
onKeyDown={(e) => e.key === "Enter" && onUpdatePagesFromCSV()}
@@ -1,5 +1,6 @@
import { useState } from "react";
import { Button, Text, NumberInput, Group } from "@mantine/core";
import { Text, NumberInput, Group } from "@mantine/core";
import { Button } from "@app/ui/Button";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
interface SelectPagesProps {
@@ -5,7 +5,8 @@ import React, {
useCallback,
useRef,
} from "react";
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
import { Badge, Modal, Text, Tooltip, Group } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -365,7 +366,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
/>
<ActionIcon
ref={closeButtonRef}
variant="subtle"
variant="tertiary"
onClick={handleClose}
aria-label={t("settings.close", "Close")}
data-autofocus
@@ -0,0 +1,27 @@
/* Trigger: bare square icon button that blends into either sidebar's header. */
.app-switch-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-sm);
color: var(--color-text-4);
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.app-switch-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-2);
}
.app-switch-icon {
width: 1rem;
height: 1.0625rem;
display: block;
}
@@ -0,0 +1,82 @@
import { useTranslation } from "react-i18next";
import { Button, Dropdown } from "@app/ui";
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
import "@app/components/shared/AppSwitch.css";
export type AppSwitchTarget = "editor" | "processor";
function ChevronDownIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
interface AppSwitchProps {
/** The app this switcher is rendered in (shown as active in the menu). */
current: AppSwitchTarget;
/** Resolved color scheme; picks the brand mark for the menu items. */
theme: "light" | "dark";
/** Invoked with the selected app; only called for apps other than `current`. */
onSwitch: (app: AppSwitchTarget) => void;
className?: string;
}
/**
* The editor ⇄ processor app switcher (chevron button → app menu). The editor
* and portal sidebars render this same element so the two apps present one
* identical switcher; each host supplies its own theme source and navigation.
*/
export function AppSwitch({
current,
theme,
onSwitch,
className,
}: AppSwitchProps) {
const { t } = useTranslation();
const mark = theme === "dark" ? markDark : markLight;
const apps: Array<{ id: AppSwitchTarget; label: string }> = [
{
id: "processor",
label: t("portal.shell.sidebar.appProcessor", "Processor"),
},
{ id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") },
];
return (
<Dropdown.Root align="end" className={className}>
<Dropdown.Trigger>
<Button
variant="tertiary"
className="app-switch-btn"
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
>
<ChevronDownIcon />
</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="11rem">
{apps.map((app) => (
<Dropdown.Item
key={app.id}
active={current === app.id}
onSelect={app.id === current ? undefined : () => onSwitch(app.id)}
leading={<img className="app-switch-icon" src={mark} alt="" />}
>
{app.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Root>
);
}
@@ -0,0 +1,8 @@
/**
* Core stub for the sidebar app switcher. Builds that bundle the admin portal
* (proprietary/saas) shadow this with a real switcher; core has no portal, so
* there is nothing to switch to.
*/
export function AppSwitcher() {
return null;
}
@@ -3,13 +3,13 @@ import {
Modal,
Stack,
Text,
Button,
Group,
Alert,
TextInput,
Paper,
Select,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import LinkIcon from "@mui/icons-material/Link";
import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded";
import { useTranslation } from "react-i18next";
@@ -243,8 +243,8 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
label={t("storageShare.linkLabel", "Share link")}
rightSection={
<Button
variant="subtle"
size="xs"
variant="tertiary"
size="sm"
leftSection={
<ContentCopyRoundedIcon style={{ fontSize: 16 }} />
}
@@ -297,7 +297,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({
</Paper>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={isWorking}>
<Button variant="secondary" onClick={onClose} disabled={isWorking}>
{t("cancel", "Cancel")}
</Button>
<Button
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Stack, Text, Button, Group, Alert } from "@mantine/core";
import { Modal, Stack, Text, Group, Alert } from "@mantine/core";
import { Button } from "@app/ui/Button";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { useTranslation } from "react-i18next";
@@ -151,7 +152,7 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={isUploading}>
<Button variant="secondary" onClick={onClose} disabled={isUploading}>
{t("cancel", "Cancel")}
</Button>
<Button
@@ -8,6 +8,14 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
// The shared SegmentedControl renders each option as a radio <input> (inside a
// <label>) whose `value` attribute matches the option value. Select by value
// since it is stable regardless of how the label is wrapped (e.g. FitText).
const getRadioByValue = (container: HTMLElement, value: string) =>
container.querySelector<HTMLInputElement>(
`input[type="radio"][value="${value}"]`,
);
describe("ButtonSelector", () => {
const mockOnChange = vi.fn();
@@ -15,7 +23,7 @@ describe("ButtonSelector", () => {
vi.clearAllMocks();
});
test("should render all options as buttons", () => {
test("should render all options as segments", () => {
const options = [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
@@ -37,13 +45,13 @@ describe("ButtonSelector", () => {
expect(screen.getByText("Option 2")).toBeInTheDocument();
});
test("should highlight selected button with filled variant", () => {
test("should mark selected option as checked", () => {
const options = [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
];
render(
const { container } = render(
<TestWrapper>
<ButtonSelector
value="option1"
@@ -54,22 +62,22 @@ describe("ButtonSelector", () => {
</TestWrapper>,
);
const selectedButton = screen.getByRole("button", { name: "Option 1" });
const unselectedButton = screen.getByRole("button", { name: "Option 2" });
const selectedRadio = getRadioByValue(container, "option1");
const unselectedRadio = getRadioByValue(container, "option2");
// Check data-variant attribute for filled/outline
expect(selectedButton).toHaveAttribute("data-variant", "filled");
expect(unselectedButton).toHaveAttribute("data-variant", "outline");
// Selected option is marked via the radio's checked state.
expect(selectedRadio).toBeChecked();
expect(unselectedRadio).not.toBeChecked();
expect(screen.getByText("Selection Label")).toBeInTheDocument();
});
test("should call onChange when button is clicked", () => {
test("should call onChange when an option is clicked", () => {
const options = [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
];
render(
const { container } = render(
<TestWrapper>
<ButtonSelector
value="option1"
@@ -79,7 +87,7 @@ describe("ButtonSelector", () => {
</TestWrapper>,
);
fireEvent.click(screen.getByRole("button", { name: "Option 2" }));
fireEvent.click(getRadioByValue(container, "option2")!);
expect(mockOnChange).toHaveBeenCalledWith("option2");
});
@@ -90,7 +98,7 @@ describe("ButtonSelector", () => {
{ value: "option2", label: "Option 2" },
];
render(
const { container } = render(
<TestWrapper>
<ButtonSelector
value={undefined}
@@ -100,17 +108,17 @@ describe("ButtonSelector", () => {
</TestWrapper>,
);
// Both buttons should be outlined when no value is selected
const button1 = screen.getByRole("button", { name: "Option 1" });
const button2 = screen.getByRole("button", { name: "Option 2" });
// No option should be checked when no value is selected
const radio1 = getRadioByValue(container, "option1");
const radio2 = getRadioByValue(container, "option2");
expect(button1).toHaveAttribute("data-variant", "outline");
expect(button2).toHaveAttribute("data-variant", "outline");
expect(radio1).not.toBeChecked();
expect(radio2).not.toBeChecked();
});
test.each([
{
description: "disable buttons when disabled prop is true",
description: "disable options when disabled prop is true",
options: [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
@@ -128,7 +136,7 @@ describe("ButtonSelector", () => {
expectedStates: [false, true],
},
])("should $description", ({ options, globalDisabled, expectedStates }) => {
render(
const { container } = render(
<TestWrapper>
<ButtonSelector
value="option1"
@@ -140,18 +148,18 @@ describe("ButtonSelector", () => {
);
options.forEach((option, index) => {
const button = screen.getByRole("button", { name: option.label });
expect(button).toHaveProperty("disabled", expectedStates[index]);
const radio = getRadioByValue(container, String(option.value));
expect(radio).toHaveProperty("disabled", expectedStates[index]);
});
});
test("should not call onChange when disabled button is clicked", () => {
test("should not allow selecting a disabled option", () => {
const options = [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2", disabled: true },
];
render(
const { container } = render(
<TestWrapper>
<ButtonSelector
value="option1"
@@ -161,12 +169,16 @@ describe("ButtonSelector", () => {
</TestWrapper>,
);
fireEvent.click(screen.getByRole("button", { name: "Option 2" }));
// The disabled option's radio is disabled, so a real user cannot select it
// and onChange will not fire from genuine interaction. (jsdom does not
// replicate the browser's disabled-click blocking, so assert the disabled
// state — that is what prevents selection for real users.)
const disabledRadio = getRadioByValue(container, "option2");
expect(disabledRadio).toBeDisabled();
expect(mockOnChange).not.toHaveBeenCalled();
});
test("should not apply fullWidth styling when fullWidth is false", () => {
test("should render options when fullWidth is false", () => {
const options = [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
@@ -184,8 +196,7 @@ describe("ButtonSelector", () => {
</TestWrapper>,
);
const button = screen.getByRole("button", { name: "Option 1" });
expect(button).not.toHaveStyle({ flex: "1" });
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("Layout Label")).toBeInTheDocument();
});
@@ -205,14 +216,14 @@ describe("ButtonSelector", () => {
</TestWrapper>,
);
// Should render buttons
// Should render the options
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("Option 2")).toBeInTheDocument();
// Stack should only contain the Group (buttons), no Text element for label
// Stack should only contain the SegmentedControl, no label Text element
const stackElement = container.querySelector(
'[class*="mantine-Stack-root"]',
);
expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text
expect(stackElement?.children).toHaveLength(1); // Only the SegmentedControl, no label Text
});
});
@@ -1,5 +1,6 @@
import { Button, Group, Stack, Text, Tooltip } from "@mantine/core";
import { Stack, Text, Tooltip } from "@mantine/core";
import FitText from "@app/components/shared/FitText";
import { SegmentedControl } from "@app/ui/SegmentedControl";
export interface ButtonOption<T> {
value: T;
@@ -15,7 +16,6 @@ interface ButtonSelectorProps<T> {
label?: string;
disabled?: boolean;
fullWidth?: boolean;
buttonClassName?: string;
textClassName?: string;
}
@@ -26,9 +26,43 @@ const ButtonSelector = <T extends string | number>({
label = undefined,
disabled = false,
fullWidth = true,
buttonClassName,
textClassName,
}: ButtonSelectorProps<T>) => {
const selectedValue = value === undefined ? "" : String(value);
const segmentedOptions = options.map((option) => {
const isDisabled = disabled || option.disabled;
const fitText = (
<FitText
text={option.label}
lines={1}
minimumFontScale={0.5}
fontSize={10}
className={textClassName}
/>
);
return {
value: String(option.value),
disabled: isDisabled,
label:
option.tooltip && isDisabled ? (
<Tooltip label={option.tooltip} position="top" withArrow>
<span>{fitText}</span>
</Tooltip>
) : (
fitText
),
};
});
const handleChange = (next: string) => {
const matched = options.find((option) => String(option.value) === next);
if (matched) {
onChange(matched.value);
}
};
return (
<Stack gap="var(--mantine-spacing-sm)">
{/* Label (if it exists) */}
@@ -44,69 +78,12 @@ const ButtonSelector = <T extends string | number>({
</Text>
)}
{/* Buttons */}
<Group gap="4px">
{options.map((option) => {
const isDisabled = disabled || option.disabled;
const button = (
<Button
variant={value === option.value ? "filled" : "outline"}
color={
value === option.value
? "var(--color-primary-500)"
: "var(--text-muted)"
}
onClick={() => onChange(option.value)}
disabled={isDisabled}
className={buttonClassName}
style={{
flex: fullWidth ? 1 : undefined,
height: "auto",
minHeight: "2.5rem",
fontSize: "var(--mantine-font-size-sm)",
lineHeight: "1.4",
paddingTop: "0.5rem",
paddingBottom: "0.5rem",
}}
>
<FitText
text={option.label}
lines={1}
minimumFontScale={0.5}
fontSize={10}
className={textClassName}
/>
</Button>
);
// Wrap with tooltip if provided (useful for disabled state explanations)
if (option.tooltip && isDisabled) {
return (
<Tooltip
key={option.value}
label={option.tooltip}
position="top"
withArrow
>
<span
style={{ flex: fullWidth ? 1 : undefined, display: "flex" }}
>
{button}
</span>
</Tooltip>
);
}
return (
<span
key={option.value}
style={{ flex: fullWidth ? 1 : undefined, display: "flex" }}
>
{button}
</span>
);
})}
</Group>
<SegmentedControl
options={segmentedOptions}
value={selectedValue}
onChange={handleChange}
fullWidth={fullWidth}
/>
</Stack>
);
};

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