Compare commits

..
Author SHA1 Message Date
Connor Yoh 68f851e31f copy(portal): reword billing Enterprise CTA to "Explore Enterprise"
Was "Build your Enterprise quote" on the Usage & Billing enterprise upsell.
2026-07-10 11:47:11 +01:00
Connor Yoh ad04260f53 Merge remote-tracking branch 'origin/main' into claude/vigorous-euler-bd6445
# Conflicts:
#	frontend/editor/src/portal/components/procurement/ProcurementHome.tsx
2026-07-08 14:19:29 +01:00
Anthony StirlingandGitHub 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 BruntonandGitHub 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 BrowneandGitHub 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
Connor Yoh e1dfa5b05a feat(portal): gracefully hide editor hero when deployment endpoint is unavailable
/v1/editor/deployment isn't implemented on every backend yet. When it returns
no data (e.g. a 404 on a real server), skip the status row and fall back to
just the setup checklist instead of a stuck skeleton. It shows in full wherever
the endpoint is served (mock/dev today, real backend later) — no flag needed.

Add a DeploymentUnavailable story covering the fallback.
2026-07-08 13:09:34 +01:00
ConnorYohandGitHub 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
Connor Yoh 97de031a04 fix(portal): drop editor-hero deployment label when it repeats the host
The managed-cloud instance is named "Managed Cloud", so the hero meta line
read "Managed Cloud · Managed Cloud · …". Skip the target label when it
equals the host.
2026-07-08 11:20:09 +01:00
Anthony StirlingandGitHub 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
Connor Yoh 7aaf6ba754 Merge origin/main into claude/vigorous-euler-bd6445
Resolve conflicts:
- WelcomeCarousel.tsx: keep deleted (replaced by WelcomeBanner)
- Home.tsx: keep HomeHero composition; drop main's re-added FreeOnboarding
- ProcurementHome.tsx: keep the refactored useProcurement/banner/flow split

Migrate new portal-home components to main's new Button API
(variant primary/secondary, accent default/premium, left/rightSection),
and add a documented raw-<button> lint exemption for the hero's bespoke
chip/checklist controls (same rationale as the procurement feature's).
2026-07-08 10:38:06 +01:00
Connor Yoh 4d4de9fffa feat(portal): wire setup checklist to live policies/sources + i18n step copy
The "Finish setting up" checklist was fixture-driven with hardcoded counts and
done-states. Make it real and fully translatable:

- SetupChecklist builds its 3 steps client-side and derives completion + counts
  from the live APIs: fetchPolicies() (summary.active / recommended) and
  fetchSources() (connected = non-disabled sources). Done = count > 0.
- Step copy (titles, blurbs, chips) moves into i18n under
  portal.home.onboarding.steps.* with count interpolation; the editor-target
  labels move to portal.home.editor.target.*.
- "Download the PDF Editor" now opens https://stirling.com/download (external);
  policies/sources rows navigate to their views as before.
- Remove the obsolete OnboardingStep type, FREE/PRO_ONBOARDING fixtures, the
  /v1/onboarding endpoint + handler, and fetchOnboarding — nothing else used them.
- SetupChecklist no longer needs onTryOp; drop it from HomeHero too.

Storybook: Default derives 2/3 from the policies/sources fixtures; NotStarted
seeds empty data → 0/3.
2026-07-07 15:08:48 +01:00
Connor Yoh 8ddf2e41e2 fix(portal): subscribed hero reuses real editor-deployment API (was shadowing it)
The subscribed hero shipped its own /v1/editor/deployment mock + handler, which
was registered before editorDeployHandlers and shadowed the real endpoint —
breaking the Editor admin view (it received the hero's mini payload).

- EditorStatusCard now reads the shared fetchEditorDeployment(tier)
  (EditorDeploymentResponse): headlines the busiest healthy instance for
  host/version/region/target, sums live users for the chip, and opens the
  workspace URL.
- Add workspaceUrl to DeploymentSummary so "Open in browser" has a real target.
- Remove the duplicate EditorDeployment type, EDITOR_DEPLOYMENT fixture,
  home-layer fetchEditorDeployment, and the colliding handler.
- i18n: add portal.home.editor.name / .updated.

Note: the onboarding checklist counts/done-state remain fixture data (not yet
wired to fetchPolicies/fetchSources); step copy also lives in the fixture.
2026-07-07 14:53:21 +01:00
Connor Yoh f947e8e7c1 test(portal): add SubscribedInProcurement Home story (deal hero in footer) 2026-07-07 14:35:26 +01:00
Connor Yoh e60c271f30 refactor(portal): procurement as hero footer bolt-on; enterprise reuses editor hero
Compose the Home hero per tier with a procurement-aware footer, matching the
prototype across all states:

- Extract ProcurementHome's state/actions into a useProcurement() controller
  hook, and split its UI into ProcurementBanner (deal hero / on-ramp) and
  ProcurementFlow (takeover modals). ProcurementHome now composes these; the
  /procurement route is unchanged.
- New HomeHero: renders the tier hero (free = welcome banner, pro/enterprise =
  deployed-Editor card) and swaps its footer to the deal-status hero while a
  procurement deal is underway, otherwise the setup checklist.
- Enterprise reuses the editor hero with chips hidden (the deal hero owns the
  invite action), per the prototype.
- The setup checklist's Enterprise rung is the sole no-deal on-ramp on Home;
  ProcurementHome's standalone upsell no longer renders there (removing the
  duplicate CTA).
2026-07-07 14:26:09 +01:00
Connor Yoh 2372282e3d feat(portal): subscribed-tier deployed-Editor hero + paid greeting
Add the subscribed (Processor) home hero from the marketing prototype:

- EditorStatusCard: a deployed-Editor status hero (brand mark, active-user
  + invite chips, deployment meta line, single "Open in browser" action)
  with the setup checklist attached as its footer.
- HomeGreeting: time-of-day greeting + date above the paid-tier home.
- SetupChecklist is now tier-aware: pro shows the Processor checklist
  (4 active / 7 connected) and a "Get Quote" Enterprise rung; free keeps
  "Start Trial".
- Fixtures/endpoints: EDITOR_DEPLOYMENT (/v1/editor/deployment),
  PRO_ONBOARDING, tier-aware /v1/onboarding.
- Rename the welcome hero's CSS namespace portal-hero -> portal-welcome to
  avoid colliding with the procurement DealStatusHero's .portal-hero (both
  are global stylesheets after the main merge).

Home now composes per tier: free = welcome banner, pro = deployed-Editor
hero, both with the checklist footer; ProcurementHome stays a bolt-on.
Adds icons (UserPlus, ExternalLink) and an EditorStatusCard story.
2026-07-07 14:14:14 +01:00
Connor Yoh 79cddfd800 Merge remote-tracking branch 'origin/main' into claude/vigorous-euler-bd6445
# Conflicts:
#	frontend/editor/src/portal/views/Home.tsx
2026-07-07 13:59:52 +01:00
Connor Yoh 8d97cff8eb feat(portal): replace free-tier carousel with static welcome hero
Rework the free-tier portal home to match the marketing prototype:

- Add a static WelcomeBanner hero (OPEN-SOURCE badge, brand-accented
  title, Install the Editor / Invite teammates CTAs, perks line, animated
  gradient blobs, and a decorative editor-card visual). Replaces the
  rotating WelcomeCarousel, which is removed.
- Add a SetupChecklist ("Finish setting up") rendered as the hero's
  attached footer: status-dot rows with trailing chips, a dismissible
  progress header, and a persistent Enterprise upsell rung.
- Update the free-tier onboarding fixture to the demo's three steps and
  extend OnboardingStep with a status chip.
- Pro/Enterprise homes drop the welcome hero (matching the prototype) and
  keep their dashboards.

Adds Storybook stories for both new components and a reusable DownloadIcon.
2026-07-07 13:40:16 +01:00
124 changed files with 4882 additions and 1526 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>
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.14.2
pkgver=2.14.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.14.2
pkgver=2.14.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+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})"
}
}
@@ -103,6 +103,7 @@ public class User implements UserDetails, Serializable {
@ElementCollection
@MapKeyColumn(name = "setting_key")
@Lob
@Column(name = "setting_value", columnDefinition = "text")
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
@JsonIgnore
+1 -1
View File
@@ -91,7 +91,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.14.2'
version = '2.14.1'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
+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
+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;
},
+4 -4
View File
@@ -29,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",
+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
@@ -6262,7 +6262,7 @@ bodyBefore = "Set"
title = "Stripe not configured"
[portal.billing.enterpriseUpsell]
cta = "Build your Enterprise quote"
cta = "Explore Enterprise"
description = "Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships."
eyebrow = "Volume discount · 1M+ PDFs"
title = "Stirling Enterprise"
@@ -6739,6 +6739,23 @@ ready = "Ready to deploy"
description = "Once documents are processed, your 30-day usage appears here."
title = "No usage yet"
[portal.home.editor]
activeUsers = "{{n}} active"
invite = "Invite teammates"
name = "Stirling PDF Editor"
open = "Open in browser"
updated = "updated {{time}}"
[portal.home.editor.target]
cloud = "Managed Cloud"
docker = "Self-hosted · Docker"
kubernetes = "Self-hosted · Kubernetes"
[portal.home.greeting]
afternoon = "Good afternoon"
evening = "Good evening"
morning = "Good morning"
[portal.home.kpis.enterprise]
docs30d = "Docs / 30d"
evalPassRate = "Eval pass rate"
@@ -6759,15 +6776,31 @@ evalPassRate = "Eval pass rate"
pipelines = "Pipelines"
[portal.home.onboarding]
progress = "{{done}} / {{total}} done"
runAgain = "Run again"
start = "Start"
subtitle = "Four steps to a production-shaped Stirling project."
title = "Get to value"
dismiss = "Dismiss setup"
notStarted = "Not started"
progress = "{{done}} of {{total}} done"
title = "Finish setting up"
[portal.home.onboarding.empty]
description = "Onboarding tasks will appear here once your workspace is set up."
title = "No onboarding steps yet"
[portal.home.onboarding.enterprise]
body = "Org-wide SSO + SCIM + RBAC, committed volume pricing, and air-gapped deployment."
cta = "Start Trial"
ctaQuote = "Get Quote"
lead = "For 250+ employees."
tag = "Enterprise"
[portal.home.onboarding.steps.editor]
blurb = "Deploy the desktop app, free forever."
title = "Download the PDF Editor"
[portal.home.onboarding.steps.policies]
blurb = "{{active}} active · {{recommended}} recommended"
chip = "{{active}} active"
title = "Turn on policies"
[portal.home.onboarding.steps.sources]
blurb = "{{connected}} connected · every PDF governed where it lands"
chip = "{{connected}} connected"
title = "Connect your sources"
[portal.home.productGrid]
ariaLabel = "Process PDFs at scale"
@@ -8015,36 +8048,14 @@ status = "Status"
suspend = "Suspend"
[portal.welcome]
ariaLabel = "Stirling product highlights"
pagination = "Carousel pagination"
slideLabel = "Slide {{number}}: {{title}}"
[portal.welcome.ornament.editor]
critical = "Critical"
ocrClean = "OCR-clean"
schemaMatch = "schema match 0.97"
signed = "signed"
[portal.welcome.slides.agents]
eyebrow = "AI Agents"
primary = "Try PDF Processor"
secondary = "View MCP docs"
sub = "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship."
title = "PDF Processor for AI Agents"
[portal.welcome.slides.editor]
eyebrow = "PDF Editor"
primary = "Install PDF Editor"
secondary = "Connect an instance"
sub = "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane."
title = "The #1 PDF Editor on GitHub"
[portal.welcome.slides.platform]
eyebrow = "Platform"
primary = "Try a PDF operation"
secondary = "Get an API key"
sub = "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls."
title = "PDF Infrastructure for Developers"
ariaLabel = "Welcome to Stirling PDF"
badge = "Open-source"
installEditor = "Install the Editor"
inviteTeammates = "Invite teammates"
perks = "Free forever · Self-hostable"
subtitle = "The world's most secure PDF Editor is free for teams of all sizes. Includes 60+ PDF operations and SSO."
title = "Welcome to"
titleAccent = "Stirling PDF"
[printFile]
title = "Print File"
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling PDF",
"mainBinaryName": "Stirling-PDF",
"version": "2.14.2",
"version": "2.14.1",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -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;
}
@@ -102,6 +102,18 @@
flex-shrink: 0;
}
/* App switcher (portal builds only) sits at the far end of the header row.
The content-fade animation makes this span a stacking context, which would
trap the menu's z-index below later sidebar rows — elevate the span so the
open menu paints above them. */
.file-sidebar-app-switch {
margin-inline-start: auto;
display: flex;
align-items: center;
position: relative;
z-index: var(--z-dropdown);
}
/* ---- Search row ---- */
.file-sidebar-search-row {
display: flex;
@@ -29,6 +29,7 @@ import {
import { accountService } from "@app/services/accountService";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { Wordmark } from "@app/components/shared/Wordmark";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import type { StirlingFileStub } from "@app/types/fileContext";
import MenuIcon from "@mui/icons-material/Menu";
import SearchIcon from "@mui/icons-material/Search";
@@ -679,6 +680,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
className="file-sidebar-brand-text sidebar-content-fade"
/>
)}
{!collapsed && (
// The header row itself toggles collapse; stop the switcher's
// clicks and key presses from reaching it.
<span
className="file-sidebar-app-switch sidebar-content-fade"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<AppSwitcher />
</span>
)}
</div>
</Tooltip>
@@ -1,5 +1,12 @@
import { buildChangeMetadataFormData } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
import {
buildChangeMetadataFormData,
changeMetadataToApiParams,
changeMetadataFromApiParams,
} from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
import {
ChangeMetadataParameters,
defaultParameters,
} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
import { TrappedStatus } from "@app/types/metadata";
import { describe, expect, test } from "vitest";
@@ -142,3 +149,78 @@ describe("buildChangeMetadataFormData", () => {
expect(formData.get("allRequestParams[customValue1]")).toBe("Engineering");
});
});
describe("changeMetadata mappers", () => {
const fullApi: Parameters<typeof changeMetadataFromApiParams>[0] = {
title: "Title",
author: "Author",
subject: "Subject",
keywords: "a, b",
creator: "Creator",
producer: "Producer",
creationDate: "2024/01/15 10:30:00",
modificationDate: "2024/02/20 14:05:09",
trapped: "True",
deleteAll: false,
allRequestParams: {
customKey1: "Department",
customValue1: "Engineering",
customKey2: "Project",
customValue2: "Falcon",
},
};
test("round-trips a full request through fromApiParams and back", () => {
const roundTripped = changeMetadataToApiParams({
...defaultParameters,
...changeMetadataFromApiParams(fullApi),
});
expect(roundTripped).toEqual(fullApi);
});
test("reconstructs dates in local time and clears absent ones", () => {
const params = changeMetadataFromApiParams(fullApi);
expect(params.creationDate).toEqual(new Date(2024, 0, 15, 10, 30, 0));
expect(params.modificationDate).toEqual(new Date(2024, 1, 20, 14, 5, 9));
const cleared = changeMetadataFromApiParams({
creationDate: "",
modificationDate: undefined,
});
expect(cleared.creationDate).toBeNull();
expect(cleared.modificationDate).toBeNull();
});
test.each([TrappedStatus.TRUE, TrappedStatus.FALSE, TrappedStatus.UNKNOWN])(
"round-trips trapped=%s",
(trapped) => {
const api = changeMetadataToApiParams({ ...defaultParameters, trapped });
expect(api.trapped).toBe(trapped);
expect(changeMetadataFromApiParams(api).trapped).toBe(trapped);
},
);
test("falls back to the default for an unrecognised trapped value", () => {
const params = changeMetadataFromApiParams({
trapped: "Bogus",
} as unknown as Parameters<typeof changeMetadataFromApiParams>[0]);
expect(params.trapped).toBe(defaultParameters.trapped);
});
test("reconstructs custom metadata, tolerating non-contiguous indices", () => {
const params = changeMetadataFromApiParams({
allRequestParams: {
customKey1: "Department",
customValue1: "Engineering",
customKey3: "Project",
customValue3: "Falcon",
},
});
expect(params.customMetadata).toEqual([
{ key: "Department", value: "Engineering", id: "custom1" },
{ key: "Project", value: "Falcon", id: "custom3" },
]);
});
});
@@ -3,13 +3,22 @@ import {
useToolOperation,
defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
type ToolApiParams,
type ToolEndpoint,
} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { TrappedStatus, CustomMetadataEntry } from "@app/types/metadata";
import {
ChangeMetadataParameters,
defaultParameters,
} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
// Helper function to format Date object to string
const ENDPOINT = "/api/v1/misc/update-metadata" satisfies ToolEndpoint;
type ChangeMetadataApiParams = ToolApiParams[typeof ENDPOINT];
// Backend date format (yyyy/MM/dd HH:mm:ss), in local time to mirror the parser.
const formatDateForBackend = (date: Date | null): string => {
if (!date) return "";
const year = date.getFullYear();
@@ -21,64 +30,135 @@ const formatDateForBackend = (date: Date | null): string => {
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
};
// Inverse of formatDateForBackend; returns null for empty or unparseable input.
const parseDateFromBackend = (value: string | undefined): Date | null => {
if (!value) return null;
const match = /^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(
value,
);
if (!match) return null;
const [, year, month, day, hours, minutes, seconds] = match;
return new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hours),
Number(minutes),
Number(seconds),
);
};
// Custom metadata is carried in the request's allRequestParams map as paired
// customKey<N>/customValue<N> entries; the backend rejoins them by the shared
// index N (see MetadataController). Only entries with a non-blank key and value
// are sent, and they are re-numbered from 1 so the indices stay contiguous.
const buildCustomMetadataMap = (
customMetadata: CustomMetadataEntry[],
): Record<string, string> | undefined => {
const validEntries = customMetadata.filter(
(entry) => entry.key.trim() && entry.value.trim(),
);
if (validEntries.length === 0) return undefined;
const map: Record<string, string> = {};
validEntries.forEach((entry, index) => {
const n = index + 1;
map[`customKey${n}`] = entry.key.trim();
map[`customValue${n}`] = entry.value.trim();
});
return map;
};
// Rebuild the UI's custom metadata list from the allRequestParams map by pairing
// customKey<N>/customValue<N> on their shared index N. Mirrors the backend
// (MetadataController), which pairs by index across all entries and does not
// assume the indices are contiguous, so a non-contiguous stored map round-trips.
const parseCustomMetadataMap = (
allRequestParams: ChangeMetadataApiParams["allRequestParams"],
): CustomMetadataEntry[] => {
if (!allRequestParams) return [];
return Object.keys(allRequestParams)
.map((key) => /^customKey(\d+)$/.exec(key)?.[1])
.filter((n): n is string => n !== undefined)
.map(Number)
.sort((a, b) => a - b)
.map((n) => ({
key: allRequestParams[`customKey${n}`] ?? "",
value: allRequestParams[`customValue${n}`] ?? "",
id: `custom${n}`,
}));
};
// Map the backend's trapped string onto the UI enum, validating against the
// actual enum values so an unrecognised value falls back to the default instead
// of being force-cast.
const parseTrapped = (value: string | undefined): TrappedStatus =>
Object.values(TrappedStatus).find((status) => status === value) ??
defaultParameters.trapped;
// Convert the tool's UI parameters into the update-metadata request body. The
// return type is the generated backend model, so a spec change that renames or
// drops a field breaks the build here.
export const changeMetadataToApiParams = (
parameters: ChangeMetadataParameters,
): ChangeMetadataApiParams => ({
title: parameters.title,
author: parameters.author,
subject: parameters.subject,
keywords: parameters.keywords,
creator: parameters.creator,
producer: parameters.producer,
creationDate: formatDateForBackend(parameters.creationDate),
modificationDate: formatDateForBackend(parameters.modificationDate),
trapped: parameters.trapped,
deleteAll: parameters.deleteAll,
allRequestParams: buildCustomMetadataMap(parameters.customMetadata),
});
// Reconstruct the tool's UI parameters from an update-metadata request body, so
// a stored or AI-authored step can be re-rendered in the settings UI.
export const changeMetadataFromApiParams = (
apiParams: ChangeMetadataApiParams,
): Partial<ChangeMetadataParameters> => ({
title: apiParams.title ?? defaultParameters.title,
author: apiParams.author ?? defaultParameters.author,
subject: apiParams.subject ?? defaultParameters.subject,
keywords: apiParams.keywords ?? defaultParameters.keywords,
creator: apiParams.creator ?? defaultParameters.creator,
producer: apiParams.producer ?? defaultParameters.producer,
creationDate: parseDateFromBackend(apiParams.creationDate),
modificationDate: parseDateFromBackend(apiParams.modificationDate),
trapped: parseTrapped(apiParams.trapped),
deleteAll: apiParams.deleteAll ?? defaultParameters.deleteAll,
customMetadata: parseCustomMetadataMap(apiParams.allRequestParams),
});
// Static function that can be used by both the hook and automation executor
export const buildChangeMetadataFormData = (
parameters: ChangeMetadataParameters,
file: File,
): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Standard metadata fields
formData.append("title", parameters.title || "");
formData.append("author", parameters.author || "");
formData.append("subject", parameters.subject || "");
formData.append("keywords", parameters.keywords || "");
formData.append("creator", parameters.creator || "");
formData.append("producer", parameters.producer || "");
// Date fields - convert Date objects to strings
formData.append(
"creationDate",
formatDateForBackend(parameters.creationDate),
);
formData.append(
"modificationDate",
formatDateForBackend(parameters.modificationDate),
);
// Trapped status
formData.append("trapped", parameters.trapped || "");
// Delete all metadata flag
formData.append("deleteAll", parameters.deleteAll.toString());
// Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]'
let keyNumber = 0;
if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) {
parameters.customMetadata.forEach((entry) => {
if (entry.key.trim() && entry.value.trim()) {
keyNumber += 1;
formData.append(
`allRequestParams[customKey${keyNumber}]`,
entry.key.trim(),
);
formData.append(
`allRequestParams[customValue${keyNumber}]`,
entry.value.trim(),
);
}
});
// allRequestParams is a Spring-bound map: objectToFormData only serializes
// primitives, so the scalar fields go through it and the map is flattened into
// allRequestParams[<key>] form fields separately.
const { allRequestParams, ...scalarParams } =
changeMetadataToApiParams(parameters);
const formData = objectToFormData(scalarParams, { fileInput: file });
for (const [key, value] of Object.entries(allRequestParams ?? {})) {
if (value !== undefined) {
formData.append(`allRequestParams[${key}]`, value);
}
}
return formData;
};
// Static configuration object
export const changeMetadataOperationConfig = defineSingleFileTool({
buildFormData: buildChangeMetadataFormData,
toApiParams: changeMetadataToApiParams,
fromApiParams: changeMetadataFromApiParams,
operationType: "changeMetadata",
endpoint: "/api/v1/misc/update-metadata",
endpoint: ENDPOINT,
defaultParameters,
});
@@ -1,5 +1,8 @@
import { describe, expect, test } from "vitest";
import { type RegistryToolOperationConfig } from "@app/hooks/tools/shared/toolOperationTypes";
import {
ToolType,
type RegistryToolOperationConfig,
} from "@app/hooks/tools/shared/toolOperationTypes";
import { objectToFormData } from "@app/hooks/tools/shared/toolApiMapping";
// Pilot tools.
@@ -17,6 +20,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale
import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
import { changeMetadataOperationConfig } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation";
import { editTableOfContentsOperationConfig } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation";
@@ -57,6 +61,7 @@ const MIGRATED_CONFIGS = [
autoRenameOperationConfig,
bookletImpositionOperationConfig,
certSignOperationConfig,
changeMetadataOperationConfig,
changePermissionsOperationConfig,
cropOperationConfig,
editTableOfContentsOperationConfig,
@@ -102,15 +107,26 @@ describe("migrated tool mappers (sweep)", () => {
expect(config.toApiParams).toBeDefined();
expect(config.fromApiParams).toBeDefined();
// toApiParams(defaults) must produce a body objectToFormData can serialize
// (i.e. only primitives / arrays of primitives). A mapper that leaked a
// structured value would throw here.
// Serialize the defaults through the tool's own buildFormData - the real
// path the executor uses - so a tool whose toApiParams carries a structured
// field that buildFormData flattens itself (e.g. changeMetadata's
// allRequestParams map) is exercised too, not just tools whose mapper
// output is directly objectToFormData-able. Custom tools have no
// buildFormData, so fall back to serializing the mapper output directly.
const params =
config.defaultParameters ?? FALLBACK_PARAMS[config.operationType] ?? {};
const apiParams = config.toApiParams!(params);
expect(() =>
objectToFormData(apiParams, { fileInput: file }),
).not.toThrow();
if (config.toolType === ToolType.multiFile) {
const build = config.buildFormData;
expect(() => build(params, [file])).not.toThrow();
} else if (config.toolType === ToolType.singleFile) {
const build = config.buildFormData;
expect(() => build(params, file)).not.toThrow();
} else {
const toApiParams = config.toApiParams!;
expect(() =>
objectToFormData(toApiParams(params), { fileInput: file }),
).not.toThrow();
}
},
);
});
@@ -0,0 +1,7 @@
/**
* Base path where the admin portal route-set mounts inside the editor app
* (see adminRouteExtensions). Lives in core so any layer can reference the
* mount point without importing portal code — build flavors that ship no
* portal (core, desktop, prototypes) must never resolve @portal.
*/
export const PORTAL_BASENAME = "/portal";
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.14.2",
appVersion: "2.14.1",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
+6
View File
@@ -36,6 +36,12 @@
font-size: 0.8125rem;
color: var(--color-text-2);
border-radius: var(--radius-sm);
/* Explicit button reset: hosts without a global button reset (e.g. the
editor) would otherwise show the UA's buttonface background and border. */
background: none;
border: none;
font-family: inherit;
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
@@ -0,0 +1,9 @@
/**
* Desktop inherits proprietary's layers but does not ship the portal (see
* desktop/routes/adminRouteExtensions), so shadow the switcher back to empty —
* otherwise the desktop bundle would reference @portal via the proprietary
* switcher's imports.
*/
export function AppSwitcher() {
return null;
}
@@ -0,0 +1,20 @@
import { TierProvider } from "@portal/contexts/TierContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { PortalChrome } from "@portal/components/PortalChrome";
/**
* SaaS provider stack. There is no account-link layer: the signed-in account IS
* the SaaS account (auth is handled upstream by PortalAuthBoundary) and the tier
* comes from the wallet (see portal-saas/contexts/usePlanTier). Dropping
* LinkProvider / AccountLinkProvider / the login modal here keeps the link
* machinery out of the SaaS bundle entirely.
*/
export function PortalProviders() {
return (
<TierProvider initialTier="pro">
<UIProvider>
<PortalChrome />
</UIProvider>
</TierProvider>
);
}
@@ -0,0 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { saasApiBase } from "@portal/api/saasApiBase";
describe("saasApiBase — SaaS build (one backend, VITE_API_BASE_URL)", () => {
afterEach(() => vi.unstubAllEnvs());
it("reuses the editor's single backend base", () => {
vi.stubEnv("VITE_API_BASE_URL", "http://localhost:8080");
expect(saasApiBase()).toBe("http://localhost:8080");
});
it("trims a trailing slash", () => {
vi.stubEnv("VITE_API_BASE_URL", "https://app.stirling.com/");
expect(saasApiBase()).toBe("https://app.stirling.com");
});
it("maps same-origin '/' to '' — a valid (non-null) base, never 'unconfigured'", () => {
vi.stubEnv("VITE_API_BASE_URL", "/");
expect(saasApiBase()).toBe("");
});
});
@@ -0,0 +1,13 @@
/**
* SaaS build: everything is the SaaS backend, so portal→SaaS reads target the
* SAME base URL the editor uses ({@code VITE_API_BASE_URL}) rather than a separate
* {@code VITE_SAAS_API_URL}. Default {@code "/"} → same-origin (the dev proxy /
* deployment forwards to the SaaS backend); the Supabase JWT is the one credential.
*
* <p>Never null — same-origin is always a valid base — so {@code apiClient.saas}
* never enters the self-hosted "SaaS not configured" state.
*/
export function saasApiBase(): string {
const raw = import.meta.env.VITE_API_BASE_URL ?? "/";
return raw.replace(/\/+$/, "");
}
@@ -0,0 +1,60 @@
import { type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { allowConsole } from "@app/tests/failOnConsole";
// Controllable auth state for the mocked provider.
const authState: { session: unknown; loading: boolean } = {
session: null,
loading: false,
};
vi.mock("@app/auth", () => ({
// Passthrough — we drive gating via the mocked useAuth below.
AuthProvider: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@app/auth/context", () => ({ useAuth: () => authState }));
vi.mock("@app/ui", () => ({ Spinner: () => null }));
vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary";
describe("PortalAuthBoundary — SaaS", () => {
beforeEach(() => {
authState.session = null;
authState.loading = false;
});
it("renders the portal when a Supabase session is present (no login)", () => {
authState.session = { user: { id: "u1" }, access_token: "tok" };
render(
<PortalAuthBoundary>
<div data-testid="portal">PORTAL</div>
</PortalAuthBoundary>,
);
expect(screen.getByTestId("portal")).toBeInTheDocument();
});
it("gates (does not render the portal) when there is no session", () => {
authState.session = null;
// The gate bounces to /login; jsdom doesn't implement navigation, so absorb
// that incidental warning rather than fail the console guard.
allowConsole.error(/not implemented|navigation/i);
render(
<PortalAuthBoundary>
<div data-testid="portal">PORTAL</div>
</PortalAuthBoundary>,
);
expect(screen.queryByTestId("portal")).not.toBeInTheDocument();
});
it("gates while the session is still resolving", () => {
authState.loading = true;
render(
<PortalAuthBoundary>
<div data-testid="portal">PORTAL</div>
</PortalAuthBoundary>,
);
expect(screen.queryByTestId("portal")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,61 @@
import { useEffect, type ReactNode } from "react";
import { AuthProvider } from "@app/auth";
import { useAuth } from "@app/auth/context";
import { Spinner } from "@app/ui";
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
function FullScreen({ children }: { children: ReactNode }) {
return (
<div
style={{
minHeight: "100dvh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{children}
</div>
);
}
/**
* SaaS gate: viewing your own usage is not admin-gated, so require only a session
* (not portalAccess). No session → bounce to the editor's Supabase login, which
* returns here signed in. This is deliberately laxer than the self-hosted
* RequirePortalAccess admin gate.
*/
function SaasPortalGate({ children }: { children: ReactNode }) {
const { session, loading } = useAuth();
useEffect(() => {
if (!loading && !session) {
window.location.href = "/login";
}
}, [loading, session]);
if (loading || !session) {
return (
<FullScreen>
<Spinner size="lg" />
</FullScreen>
);
}
return <>{children}</>;
}
/**
* SaaS override of the portal auth boundary: authenticate against the SaaS Supabase
* project (inheriting the editor's session) instead of the self-hosted Spring login.
*/
export function PortalAuthBoundary({ children }: { children: ReactNode }) {
// Configure the shared Supabase client (SaaS project) synchronously here, before
// the AuthProvider below reads it — a useEffect would run too late for the first
// render, leaving the provider with a null client. Idempotent; the portal mounts
// outside the editor's AppProviders but against the SAME project, so a user already
// signed into the editor is picked up from the persisted session (no second login).
ensureSaasSupabase();
return (
<AuthProvider mode="supabase">
<SaasPortalGate>{children}</SaasPortalGate>
</AuthProvider>
);
}
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
describe("LinkAccountFooterItem (SaaS)", () => {
it("renders nothing — SaaS has no account to link", () => {
const { container } = render(<LinkAccountFooterItem />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,7 @@
/**
* SaaS has no account-link concept — the signed-in account IS the SaaS account,
* so there is nothing to link and no footer CTA to show.
*/
export function LinkAccountFooterItem() {
return null;
}
@@ -0,0 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
vi.mock("@portal/views/Usage", () => ({
Usage: () => <div data-testid="usage" />,
}));
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
describe("PortalBillingGate — SaaS", () => {
it("renders the Usage page directly, with no link concept", () => {
render(<PortalBillingGate />);
expect(screen.getByTestId("usage")).toBeInTheDocument();
});
});
@@ -0,0 +1,20 @@
import { Usage } from "@portal/views/Usage";
/**
* SaaS billing gate: there is no link concept — the signed-in account IS the SaaS
* account (auth is handled upstream by PortalAuthBoundary), so render the Usage
* page directly. No link state, no prompt.
*
* onReauth sends the user back to the editor's Supabase login if the session
* lapses mid-view: PortalAuthBoundary only re-gates on mount / session change, so
* without this a "Session expired" notice would dead-end until a manual reload.
*/
export function PortalBillingGate() {
return (
<Usage
onReauth={() => {
window.location.href = "/login";
}}
/>
);
}
@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings";
describe("accountLinkSettings (SaaS)", () => {
it("is null — Settings has no account-link section on SaaS", () => {
expect(accountLinkSettings).toBeNull();
});
});
@@ -0,0 +1,9 @@
import type { AccountLinkSettingsSeam } from "@portal-proprietary/components/settings/accountLinkSettings";
/**
* SaaS has no account-link concept — the signed-in account IS the SaaS account.
* Null drops the "Account link" nav item and its panel from Settings (the shared
* SettingsModal treats the seam as optional), so the link-only AccountLinkPanel
* is never imported into the SaaS bundle.
*/
export const accountLinkSettings: AccountLinkSettingsSeam | null = null;
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
const fetchWallet = vi.fn();
vi.mock("@portal/api/billing", () => ({
fetchWallet: () => fetchWallet(),
}));
// Resolves to the SaaS override (src/portal-saas/contexts) via the @portal cascade.
import { usePlanTier } from "@portal/contexts/usePlanTier";
function Probe() {
return <span data-testid="tier">{usePlanTier()}</span>;
}
describe("usePlanTier (SaaS) — tier from wallet", () => {
beforeEach(() => {
fetchWallet.mockReset();
});
it("subscribed wallet → pro", async () => {
fetchWallet.mockResolvedValue({ status: "subscribed" });
const { getByTestId } = render(<Probe />);
await waitFor(() => expect(getByTestId("tier").textContent).toBe("pro"));
});
it("free wallet → free (also the loading default)", async () => {
fetchWallet.mockResolvedValue({ status: "free" });
const { getByTestId } = render(<Probe />);
// Free before the fetch resolves and after — it never flips to pro.
expect(getByTestId("tier").textContent).toBe("free");
await waitFor(() => expect(fetchWallet).toHaveBeenCalledTimes(1));
expect(getByTestId("tier").textContent).toBe("free");
});
});
@@ -0,0 +1,14 @@
import { useAsync } from "@portal/hooks/useAsync";
import { fetchWallet } from "@portal/api/billing";
import type { Tier } from "@portal/contexts/TierContext";
/**
* The plan tier the portal runs on, SaaS flavor: the signed-in account IS the
* SaaS account, so the tier comes straight from the wallet — no link concept.
* `subscribed` → the metered Processor tier; anything else (including while the
* wallet is still loading) → free. Enterprise stays a mocks-only tier.
*/
export function usePlanTier(): Tier {
const { data: wallet } = useAsync(() => fetchWallet(), []);
return wallet?.status === "subscribed" ? "pro" : "free";
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
// Resolves to the SaaS override (src/portal-saas/contexts) via the @portal cascade.
import { usePortalLinked } from "@portal/contexts/usePortalLinked";
function Probe() {
return <span data-testid="linked">{String(usePortalLinked())}</span>;
}
describe("usePortalLinked (SaaS)", () => {
it("is always true — no account-link step, no LinkProvider needed", () => {
// Renders with no LinkProvider in the tree: the SaaS override must not read it.
const { getByTestId } = render(<Probe />);
expect(getByTestId("linked").textContent).toBe("true");
});
});
@@ -0,0 +1,9 @@
/**
* SaaS flavor: the signed-in account IS the SaaS account, so the portal is always
* authorized — there is no account-link step. Procurement/checkout treats this as
* "linked" and proceeds straight to the flow. No LinkContext dependency, so the
* link machinery stays out of the SaaS bundle.
*/
export function usePortalLinked(): boolean {
return true;
}
-1
View File
@@ -52,7 +52,6 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
| Home | `GET /v1/analytics/usage` | — | `fetchUsageSeries` | `UsageSeriesResponse` |
| Home | `GET /v1/activity` | — | `fetchRecentActivity` | `ActivityEvent[]` |
| Home | `GET /v1/regions/health` | — | `fetchRegionHealth` | `RegionHealth[]` |
| Home | `GET /v1/onboarding` | — | `fetchOnboarding` | `OnboardingStep[]` |
| Users | `GET /v1/users` | `tier` | `fetchUsers` | `UsersResponse` |
| Documents | `GET /v1/documents` | `tier` | `fetchDocuments` | `DocumentsResponse` |
| Pipelines | `GET /v1/pipelines` · `POST /v1/pipelines/:id/promote-to-policy` | `tier` | `fetchPipelines` · `promoteToPolicy` | `PipelinesResponse` |
+9 -124
View File
@@ -1,25 +1,8 @@
import { useEffect, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { AuthProvider } from "@app/auth";
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
import { type ReactNode } from "react";
import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
import { TierProvider } from "@portal/contexts/TierContext";
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin";
import { UIProvider, useUI } from "@portal/contexts/UIContext";
import { SuiProvider } from "@portal/theme/SuiProvider";
import { AppShell } from "@portal/components/AppShell";
import { AuthGate } from "@portal/components/AuthGate";
import { AssistantButton } from "@portal/components/AssistantButton";
import { AssistantPanel } from "@portal/components/AssistantPanel";
import { SearchModal } from "@portal/components/SearchModal";
import { SettingsModal } from "@portal/components/SettingsModal";
import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal";
import {
AccountLinkProvider,
useAccountLinkContext,
} from "@portal/contexts/AccountLinkContext";
import { ViewRouter } from "@portal/ViewRouter";
import { PortalProviders } from "@portal/PortalProviders";
// Reset + typography, scoped to .portal-scope below.
import "@portal/theme/base.css";
@@ -33,93 +16,15 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) {
return <SuiProvider colorScheme={theme}>{children}</SuiProvider>;
}
/**
* Global keyboard shortcuts. Lives below the UIProvider so it can dispatch
* into the overlay state. Currently just ⌘K / Ctrl+K to toggle the search
* palette.
*/
function GlobalShortcuts() {
const { toggleSearch, closeSearch } = useUI();
useEffect(() => {
function onKey(e: KeyboardEvent) {
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
if (isCmdK) {
e.preventDefault();
toggleSearch();
return;
}
if (e.key === "Escape") {
closeSearch();
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [toggleSearch, closeSearch]);
return null;
}
/** Bridges the Settings modal's open/close props to UIContext state. */
function SettingsHost() {
const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
return (
<SettingsModal
open={settingsOpen}
onClose={closeSettings}
initialSection={settingsInitialSection}
/>
);
}
/**
* The one and only account-link login modal. Mounted at the app root (never
* nested in another overlay) and driven by UIContext, so any "Link account" CTA
* — sidebar, billing prompt, feature gate, Settings panel — opens this exact
* instance. Linking is finished by the shared {@link useAccountLinkContext}
* orchestration.
*/
function LinkModalHost() {
const { linkModalOpen, linkModalMode, closeLinkModal } = useUI();
const { markSaasSessionChanged } = useLink();
const link = useAccountLinkContext();
// "reauth" only refreshes the browser SaaS session for attended reads — the
// sign-in already applied it to the Supabase client, so we just signal a
// refetch. It must NOT call completeLink (that re-registers → duplicate row).
const onLinked =
linkModalMode === "reauth"
? () => markSaasSessionChanged()
: (session: SupabaseLoginSession) => link.completeLink(session);
return (
<LinkAccountModal
open={linkModalOpen}
mode={linkModalMode}
onClose={closeLinkModal}
onLinked={onLinked}
/>
);
}
/**
* The routed view, wrapped in an error boundary so a single view crashing can't
* white-screen the portal (the shell + nav stay alive). Keyed by route so
* navigating to another section clears any error from the previous one.
*/
function RoutedContent() {
const { pathname } = useLocation();
return (
<ErrorBoundary key={pathname}>
<ViewRouter />
</ErrorBoundary>
);
}
/**
* The portal, mounted as a route-set under /portal/* inside the editor app (via
* the admin-route seam). It supplies its own providers and its own i18next
* instance (the `portal` namespace), but NOT a router — the editor's
* <BrowserRouter> is the one and only router; the portal's routes are relative
* to the /portal mount (see ViewRouter).
*
* The provider stack itself is a per-flavor seam (see {@link PortalProviders}):
* self-hosted mounts the account-link layer, SaaS does not.
*/
export function PortalApp() {
return (
@@ -127,29 +32,9 @@ export function PortalApp() {
<ThemedSuiProvider>
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
<div className="portal-scope">
<AuthProvider mode="spring">
<LinkProvider initialState="unlinked">
{/* TierProvider sits INSIDE LinkProvider so it can derive the tier
from the real link/subscription state when MSW mocks are off. */}
<TierProvider initialTier="pro">
<UIProvider>
<GlobalShortcuts />
<AuthGate>
<AccountLinkProvider>
<AppShell>
<RoutedContent />
</AppShell>
<AssistantButton />
<AssistantPanel />
<SearchModal />
<SettingsHost />
<LinkModalHost />
</AccountLinkProvider>
</AuthGate>
</UIProvider>
</TierProvider>
</LinkProvider>
</AuthProvider>
<PortalAuthBoundary>
<PortalProviders />
</PortalAuthBoundary>
</div>
</ThemedSuiProvider>
</ThemeProvider>
@@ -0,0 +1,63 @@
import { TierProvider } from "@portal/contexts/TierContext";
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
import { UIProvider, useUI } from "@portal/contexts/UIContext";
import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin";
import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal";
import {
AccountLinkProvider,
useAccountLinkContext,
} from "@portal/contexts/AccountLinkContext";
import { PortalChrome } from "@portal/components/PortalChrome";
/**
* The one and only account-link login modal. Mounted at the app root (never
* nested in another overlay) and driven by UIContext, so any "Link account" CTA
* — sidebar, billing prompt, feature gate, Settings panel — opens this exact
* instance. Linking is finished by the shared {@link useAccountLinkContext}
* orchestration.
*/
function LinkModalHost() {
const { linkModalOpen, linkModalMode, closeLinkModal } = useUI();
const { markSaasSessionChanged } = useLink();
const link = useAccountLinkContext();
// "reauth" only refreshes the browser SaaS session for attended reads — the
// sign-in already applied it to the Supabase client, so we just signal a
// refetch. It must NOT call completeLink (that re-registers → duplicate row).
const onLinked =
linkModalMode === "reauth"
? () => markSaasSessionChanged()
: (session: SupabaseLoginSession) => link.completeLink(session);
return (
<LinkAccountModal
open={linkModalOpen}
mode={linkModalMode}
onClose={closeLinkModal}
onLinked={onLinked}
/>
);
}
/**
* Self-hosted provider stack. The account-link layer (LinkProvider +
* AccountLinkProvider + the login modal) wraps the shared chrome; the tier is
* derived from the link/subscription state (see usePlanTier). TierProvider sits
* inside LinkProvider because the self-hosted usePlanTier reads useLink.
*
* The SaaS build shadows this file to drop the account-link layer entirely — the
* signed-in account IS the SaaS account, so there is nothing to link and the
* tier comes from the wallet.
*/
export function PortalProviders() {
return (
<LinkProvider initialState="unlinked">
<TierProvider initialTier="pro">
<UIProvider>
<AccountLinkProvider>
<PortalChrome />
<LinkModalHost />
</AccountLinkProvider>
</UIProvider>
</TierProvider>
</LinkProvider>
);
}
+2 -2
View File
@@ -9,7 +9,7 @@ import { Policies } from "@portal/views/Policies";
import { Components } from "@portal/views/Components";
import { EditorAdmin } from "@portal/views/EditorAdmin";
import { Infrastructure } from "@portal/views/Infrastructure";
import { Usage } from "@portal/views/Usage";
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
import { Procurement } from "@portal/views/Procurement";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
@@ -39,7 +39,7 @@ export function ViewRouter() {
path={rel(VIEW_PATHS.infrastructure)}
element={<Infrastructure />}
/>
<Route path={rel(VIEW_PATHS.usage)} element={<Usage />} />
<Route path={rel(VIEW_PATHS.usage)} element={<PortalBillingGate />} />
<Route path={rel(VIEW_PATHS.procurement)} element={<Procurement />} />
<Route path={rel(VIEW_PATHS.docs)} element={<DeveloperDocs />} />
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
-7
View File
@@ -2,7 +2,6 @@ import { apiClient } from "@portal/api/http";
import type {
ActivityEvent,
KpiEntry,
OnboardingStep,
RegionHealth,
UsageSeriesResponse,
} from "@portal/mocks/home";
@@ -12,7 +11,6 @@ export type {
ActivityEvent,
ActivityKind,
KpiEntry,
OnboardingStep,
PipelineStage,
PipelineTemplate,
RegionHealth,
@@ -44,8 +42,3 @@ export async function fetchHomeKpis(tier: Tier): Promise<KpiEntry[]> {
export async function fetchRegionHealth(): Promise<RegionHealth[]> {
return apiClient.local.json<RegionHealth[]>("/v1/regions/health");
}
/** GET /v1/onboarding (Free) */
export async function fetchOnboarding(): Promise<OnboardingStep[]> {
return apiClient.local.json<OnboardingStep[]>("/v1/onboarding");
}
+14 -18
View File
@@ -40,14 +40,17 @@
* admin and uses the Supabase JWT for SaaS reads. Don't add it here.
*/
import { clearStoredToken, getStoredToken } from "@app/auth";
import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
import { getPortalSaasToken } from "@portal/auth/portalSaasSession";
import { saasApiBase } from "@portal/api/saasApiBase";
/** Read the SaaS base URL at call time so tests can stub it via vi.stubEnv. */
/**
* SaaS base URL via the flavor seam: self-hosted reads VITE_SAAS_API_URL (a
* separate cloud backend); the SaaS build reuses the editor's single
* VITE_API_BASE_URL (in SaaS everything is the SaaS backend). {@code null} means
* not configured — a self-hosted-only state; the SaaS seam never returns null.
*/
function saasBaseUrl(): string | null {
const raw = import.meta.env.VITE_SAAS_API_URL;
if (!raw) return null;
return raw.replace(/\/+$/, "");
return saasApiBase();
}
export interface HttpRequestOptions {
@@ -169,21 +172,14 @@ async function localJson<T>(
// saas — hosted SaaS Java, admin's Supabase JWT
// ────────────────────────────────────────────────────────────────────────────
async function getSaasAccessToken(): Promise<string | null> {
ensureSaasSupabase();
const supabase = getSupabaseClient();
if (!supabase) return null;
const { data } = await supabase.auth.getSession();
return data.session?.access_token ?? null;
}
async function saasJson<T>(
path: string,
options: HttpRequestOptions = {},
): Promise<T> {
const base = saasBaseUrl();
if (!base) throw new SaasUnconfiguredError();
const token = await getSaasAccessToken();
// null = unset (self-hosted, no VITE_SAAS_API_URL). "" is same-origin (SaaS) — valid.
if (base === null) throw new SaasUnconfiguredError();
const token = await getPortalSaasToken();
if (!token) throw new SaasNotLinkedError();
const res = await fetch(`${base}${path}`, {
method: options.method ?? "GET",
@@ -213,7 +209,7 @@ export const apiClient = {
/** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */
saas: {
json: saasJson,
/** True when VITE_SAAS_API_URL is set. Doesn't check session liveness. */
isConfigured: (): boolean => Boolean(saasBaseUrl()),
/** True when a SaaS base URL is resolvable. Doesn't check session liveness. */
isConfigured: (): boolean => saasBaseUrl() !== null,
},
} as const;
@@ -0,0 +1,18 @@
/**
* Base URL for attended portal→SaaS reads ({@code apiClient.saas}) — the seam the
* SaaS build overrides.
*
* <p>Self-hosted (this base): the SaaS cloud is a <em>separate</em> backend from
* this instance's local one, configured via {@code VITE_SAAS_API_URL}. Returns
* {@code null} when unset so {@code apiClient.saas} can surface a clear
* "configure" state. A set value has any trailing slash trimmed.
*
* <p>The SaaS build shadows this to reuse the editor's single backend
* ({@code VITE_API_BASE_URL}) — in SaaS everything is the SaaS backend, so there
* is no separate SaaS URL.
*/
export function saasApiBase(): string | null {
const raw = import.meta.env.VITE_SAAS_API_URL;
if (!raw) return null;
return raw.replace(/\/+$/, "");
}
@@ -0,0 +1,19 @@
import { type ReactNode } from "react";
import { AuthProvider } from "@app/auth";
import { AuthGate } from "@portal/components/AuthGate";
/**
* Portal auth wiring — the seam the SaaS build overrides.
*
* <p>Self-hosted (this base): the portal is its own standalone app, so it owns a
* Spring {@link AuthProvider} and a Spring login gate ({@link AuthGate}). The SaaS
* build shadows this file to authenticate against the SaaS Supabase project (the
* same session the editor uses) with no Spring login and no account-link step.
*/
export function PortalAuthBoundary({ children }: { children: ReactNode }) {
return (
<AuthProvider mode="spring">
<AuthGate>{children}</AuthGate>
</AuthProvider>
);
}
+11 -4
View File
@@ -3,8 +3,15 @@
* bouncing non-admins out).
*
* Sourced from VITE_EDITOR_URL so it's configurable per deploy rather than
* hardcoded. The committed default is "/" (production serves the editor at the
* root on the same origin as the portal). For dev cross-app navigation to a
* separately-running editor, set VITE_EDITOR_URL in editor/.env.local.
* hardcoded, falling back to "/" (the editor serves the portal at /portal on
* the same origin, so the root is the editor). For dev cross-app navigation to
* a separately-running editor, set VITE_EDITOR_URL in editor/.env.local.
*/
export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL;
export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL || "/";
/**
* When the editor is at the root of this origin, the portal and editor are
* route-sets of one SPA — switching apps can be a client-side navigation
* instead of a full page load.
*/
export const EDITOR_IS_SAME_APP = EDITOR_URL === "/";
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Hoisted so the vi.mock factories can reference it before the import runs.
const { state } = vi.hoisted(() => ({
state: { client: null as { auth: { getSession: () => unknown } } | null },
}));
vi.mock("@app/auth/supabase/supabaseClient", () => ({
getSupabaseClient: () => state.client,
}));
vi.mock("@portal/auth/saasSupabase", () => ({
ensureSaasSupabase: vi.fn(),
}));
import { getPortalSaasToken } from "@portal/auth/portalSaasSession";
describe("getPortalSaasToken — self-hosted (account-link login)", () => {
beforeEach(() => {
state.client = null;
});
it("returns null when the SaaS client isn't configured (not signed in)", async () => {
expect(await getPortalSaasToken()).toBeNull();
});
it("returns the access token from the current session", async () => {
state.client = {
auth: {
getSession: async () => ({
data: { session: { access_token: "tok-123" } },
}),
},
};
expect(await getPortalSaasToken()).toBe("tok-123");
});
it("returns null when there is a client but no active session", async () => {
state.client = {
auth: { getSession: async () => ({ data: { session: null } }) },
};
expect(await getPortalSaasToken()).toBeNull();
});
});
@@ -0,0 +1,19 @@
import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
/**
* SaaS access token for attended portal→SaaS reads. Reads the shared Supabase
* client, which {@link ensureSaasSupabase} configures against the one Stirling
* Supabase project (VITE_SUPABASE_*) both flavors talk to: self-hosted mints a
* SaaS JWT in-app for account-linking (alongside its own Spring session); SaaS is
* already signed into that same project, so the editor session is inherited.
* Flavor-agnostic — no per-flavor override needed. Returns null until a session
* exists.
*/
export async function getPortalSaasToken(): Promise<string | null> {
ensureSaasSupabase();
const supabase = getSupabaseClient();
if (!supabase) return null;
const { data } = await supabase.auth.getSession();
return data.session?.access_token ?? null;
}
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock only the low-level client factory — NOT ensureSaasSupabase itself — so this
// exercises the real configurator and proves it wires the shared client from the
// one Stirling Supabase env (VITE_SUPABASE_*), shared by every flavor.
const configureSupabase = vi.fn();
const getSupabaseClient = vi.fn(() => ({ __client: true }));
vi.mock("@app/auth/supabase/supabaseClient", () => ({
configureSupabase,
getSupabaseClient,
}));
describe("ensureSaasSupabase — configures the shared client from VITE_SUPABASE_*", () => {
afterEach(() => {
vi.resetModules();
vi.unstubAllEnvs();
configureSupabase.mockClear();
getSupabaseClient.mockClear();
});
it("configures from VITE_SUPABASE_* and returns the client", async () => {
vi.stubEnv("VITE_SUPABASE_URL", "https://proj.supabase.co");
vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY", "anon-key");
// Dynamic import so the module reads the stubbed env at load.
const { ensureSaasSupabase, isSaasSupabaseConfigured } =
await import("@portal/auth/saasSupabase");
expect(isSaasSupabaseConfigured).toBe(true);
const client = ensureSaasSupabase();
expect(configureSupabase).toHaveBeenCalledWith({
url: "https://proj.supabase.co",
key: "anon-key",
});
expect(client).not.toBeNull();
});
it("stays unconfigured (client null) when the Supabase env is absent", async () => {
vi.stubEnv("VITE_SUPABASE_URL", "");
vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY", "");
const { ensureSaasSupabase, isSaasSupabaseConfigured } =
await import("@portal/auth/saasSupabase");
expect(isSaasSupabaseConfigured).toBe(false);
expect(ensureSaasSupabase()).toBeNull();
expect(configureSupabase).not.toHaveBeenCalled();
});
});
@@ -4,18 +4,21 @@ import {
} from "@app/auth/supabase/supabaseClient";
/**
* Configures the shared Supabase client against the hosted SaaS project so the
* portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a
* Configures the shared Supabase client against the Stirling Supabase project so
* the portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a
* separate, transient SaaS auth — the portal's own session stays Spring (the
* local instance admin); calls to the local backend still carry the Spring
* bearer, and the SaaS JWT is passed only in the link request body.
*
* Config: VITE_SAAS_SUPABASE_URL + VITE_SAAS_SUPABASE_ANON_KEY (both public).
* Absent → {@link isSaasSupabaseConfigured} is false and the link UI degrades to
* a "configure the SaaS Supabase URL" state.
* Config: VITE_SUPABASE_URL + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY (both public)
* — the one Stirling Supabase project every flavor talks to (same vars the editor
* and proprietary billing client use; there is no separate SaaS project). SaaS
* needs no per-flavor override: the signed-in editor session is on this same
* project, so the client picks it up. Absent → {@link isSaasSupabaseConfigured}
* is false and the link UI degrades to a "configure Supabase" state.
*/
const url = import.meta.env.VITE_SAAS_SUPABASE_URL;
const key = import.meta.env.VITE_SAAS_SUPABASE_ANON_KEY;
const url = import.meta.env.VITE_SUPABASE_URL;
const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
export const isSaasSupabaseConfigured = Boolean(url && key);
+1 -1
View File
@@ -71,7 +71,7 @@ async function invoke<T>(
const supabase = getSupabaseClient();
if (!supabase) {
throw new StripeFunctionError(
"SaaS Supabase not configured — set VITE_SAAS_SUPABASE_URL.",
"SaaS Supabase not configured — set VITE_SUPABASE_URL.",
"unconfigured",
);
}
@@ -0,0 +1,118 @@
/* ──────────────────────────────────────────────────────────────────────── */
/* Subscribed-tier deployed-Editor hero */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-editor-hero {
border-radius: var(--radius-xl);
border: 1px solid var(--color-border-input);
background: var(--color-surface);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.portal-editor-hero__row {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 1.25rem 1.5rem;
}
.portal-editor-hero__logo {
width: 3.5rem;
height: 3.5rem;
border-radius: 0.875rem;
overflow: hidden;
flex-shrink: 0;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(0, 0, 0, 0.06);
}
.portal-editor-hero__mark {
width: 100%;
height: 100%;
display: block;
}
.portal-editor-hero__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-editor-hero__title-row {
display: flex;
align-items: center;
gap: 0.5625rem;
flex-wrap: wrap;
}
.portal-editor-hero__name {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-text-1);
margin-right: 0.125rem;
}
.portal-editor-hero__chip {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
padding: 0.3125rem 0.75rem;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border-input);
background: var(--color-bg-code);
color: var(--color-text-2);
font-size: 0.71875rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
transition:
background var(--motion-fast),
box-shadow var(--motion-fast),
transform var(--motion-fast);
}
.portal-editor-hero__chip:hover {
background: var(--color-surface);
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.1);
transform: translateY(-1px);
}
.portal-editor-hero__chip svg {
color: var(--color-text-4);
}
.portal-editor-hero__meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.75rem;
color: var(--color-text-5);
}
.portal-editor-hero__host {
color: var(--color-text-2);
font-family: var(--font-mono);
font-weight: 500;
}
.portal-editor-hero__meta-sep {
color: var(--color-border-input);
}
.portal-editor-hero__action {
flex-shrink: 0;
}
@media (max-width: 48rem) {
.portal-editor-hero__row {
flex-wrap: wrap;
}
.portal-editor-hero__action {
width: 100%;
}
}
/* Attached footer strip (setup checklist). */
.portal-editor-hero__footer {
border-top: 1px solid var(--color-border-light);
}
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse } from "msw";
import { EditorStatusCard } from "@portal/components/EditorStatusCard";
import { SetupChecklist } from "@portal/components/SetupChecklist";
const meta: Meta<typeof EditorStatusCard> = {
title: "Portal/Home/EditorStatusCard",
component: EditorStatusCard,
parameters: { layout: "padded" },
globals: { tier: "pro" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof EditorStatusCard>;
/** The deployed-Editor status card on its own. */
export const Default: Story = {};
/** As it renders on the subscribed home: the setup checklist attached as the footer. */
export const WithSetupChecklist: Story = {
args: {
footer: <SetupChecklist />,
},
};
/**
* Backend without the editor-deployment endpoint (404): the status row is
* skipped and the card falls back to just the footer (setup checklist). It
* lights up automatically once /v1/editor/deployment is served.
*/
export const DeploymentUnavailable: Story = {
args: {
footer: <SetupChecklist />,
},
parameters: {
msw: {
handlers: [
http.get("/v1/editor/deployment", () =>
HttpResponse.json({ message: "not found" }, { status: 404 }),
),
],
},
},
};
@@ -0,0 +1,194 @@
import type { ReactNode } from "react";
import { Fragment, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Button, Skeleton } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useView } from "@portal/contexts/ViewContext";
import { useAsync } from "@portal/hooks/useAsync";
import {
fetchEditorDeployment,
type EditorDeploymentResponse,
type EditorInstance,
} from "@portal/api/editorDeploy";
import {
ExternalLinkIcon,
UsersIcon,
UserPlusIcon,
} from "@portal/components/icons";
import "@portal/components/EditorStatusCard.css";
/** The Stirling brand mark, drawn at the hero size. Decorative. */
function StirlingMark() {
return (
<svg
className="portal-editor-hero__mark"
viewBox="0 0 256 256"
fill="none"
aria-hidden
>
<rect width="256" height="256" rx="58" fill="#8E3131" />
<path
d="M39.2638 127.834L155.374 32L155.375 121.499L39.2638 217.333L39.2638 127.834Z"
fill="white"
/>
<path
d="M159 124.5L159 88.5L216.728 38.4472L216.728 128.052L100.479 224L100.479 172L159 124.5Z"
fill="white"
fillOpacity="0.6"
/>
</svg>
);
}
/** The instance to headline: the busiest healthy one, else the first. */
function primaryInstance(instances: EditorInstance[]): EditorInstance | null {
if (instances.length === 0) return null;
const healthy = instances.filter((i) => i.status === "healthy");
const pool = healthy.length > 0 ? healthy : instances;
return pool.reduce((best, i) =>
i.activeUsers > best.activeUsers ? i : best,
);
}
interface EditorStatusCardProps {
/**
* Rendered as an attached footer strip inside the card (e.g. the "Finish
* setting up" checklist), matching the free-tier hero's footer seam.
*/
footer?: ReactNode;
/**
* Hide the active-users / invite chips. Used on enterprise, where the
* attached procurement deal hero already owns the invite action.
*/
hideChips?: boolean;
}
/**
* Subscribed/enterprise home hero: a status card for the org's deployed PDF
* Editor. Reads the same `/v1/editor/deployment` data as the Editor admin view
* (host, version, live users, deployment shape) and headlines the busiest
* instance, with a single "Open in browser" action to the workspace URL.
*/
export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
const { t } = useTranslation();
const { tier } = useTier();
const { setActiveView } = useView();
const { data, loading } = useAsync<EditorDeploymentResponse>(
() => fetchEditorDeployment(tier),
[tier],
);
const view = useMemo(() => {
if (!data) return null;
const primary = primaryInstance(data.instances);
if (!primary) return null;
const activeUsers = data.instances.reduce((s, i) => s + i.activeUsers, 0);
const targetLabel = t(`portal.home.editor.target.${primary.target}`);
return {
host: primary.host,
activeUsers,
workspaceUrl: data.summary.workspaceUrl,
meta: [
// Skip the deployment label when it just repeats the host (e.g. the
// managed-cloud instance is literally named "Managed Cloud").
primary.host === targetLabel ? null : targetLabel,
primary.region,
`v${primary.version}`,
t("portal.home.editor.updated", { time: primary.lastSeen }),
].filter(Boolean) as string[],
};
}, [data, t]);
const ready = !loading && !!view;
// The editor-deployment endpoint isn't implemented on every backend yet.
// When it's unavailable (finished loading with no data — e.g. a 404), skip
// the status row entirely and fall back to just the footer (the setup
// checklist, which reads supported endpoints). It lights up automatically
// once the backend serves /v1/editor/deployment.
const unavailable = !loading && !view;
if (unavailable) {
return footer ? (
<section className="portal-editor-hero portal-editor-hero--footer-only">
{footer}
</section>
) : null;
}
return (
<section
className="portal-editor-hero"
aria-label={t("portal.home.editor.name")}
>
<div className="portal-editor-hero__row">
<div className="portal-editor-hero__logo">
<StirlingMark />
</div>
<div className="portal-editor-hero__info">
{!ready || !view ? (
<>
<Skeleton width="12rem" height="1.25rem" />
<Skeleton width="22rem" height="0.75rem" />
</>
) : (
<>
<div className="portal-editor-hero__title-row">
<span className="portal-editor-hero__name">
{t("portal.home.editor.name")}
</span>
{!hideChips && (
<>
<button
type="button"
className="portal-editor-hero__chip"
onClick={() => setActiveView("users")}
>
<UsersIcon size={13} />
{t("portal.home.editor.activeUsers", {
n: view.activeUsers,
})}
</button>
<button
type="button"
className="portal-editor-hero__chip"
onClick={() => setActiveView("users")}
>
<UserPlusIcon size={13} />
{t("portal.home.editor.invite")}
</button>
</>
)}
</div>
<div className="portal-editor-hero__meta">
<span className="portal-editor-hero__host">{view.host}</span>
{view.meta.map((item, i) => (
<Fragment key={i}>
<span className="portal-editor-hero__meta-sep">·</span>
<span>{item}</span>
</Fragment>
))}
</div>
</>
)}
</div>
<div className="portal-editor-hero__action">
<Button
variant="primary"
leftSection={<ExternalLinkIcon size={13} />}
disabled={!ready || !view}
onClick={() => {
if (view)
window.open(view.workspaceUrl, "_blank", "noopener,noreferrer");
}}
>
{t("portal.home.editor.open")}
</Button>
</div>
</div>
{footer && <div className="portal-editor-hero__footer">{footer}</div>}
</section>
);
}
@@ -0,0 +1,30 @@
import { useTranslation } from "react-i18next";
/**
* Time-of-day greeting + today's date, shown above the paid-tier home hero.
* Free tier opens with the welcome banner instead, so this is paid-only.
*/
export function HomeGreeting() {
const { t } = useTranslation();
const now = new Date();
const hour = now.getHours();
const greeting =
hour < 12
? t("portal.home.greeting.morning")
: hour < 18
? t("portal.home.greeting.afternoon")
: t("portal.home.greeting.evening");
const date = now.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
return (
<header className="portal-home__greeting">
<h1 className="portal-home__greeting-title">{greeting}</h1>
<p className="portal-home__greeting-date">{date}</p>
</header>
);
}
@@ -0,0 +1,42 @@
import type { Tier } from "@portal/contexts/TierContext";
import { WelcomeBanner } from "@portal/components/WelcomeBanner";
import { EditorStatusCard } from "@portal/components/EditorStatusCard";
import { SetupChecklist } from "@portal/components/SetupChecklist";
import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner";
import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow";
import { useProcurement } from "@portal/components/procurement/useProcurement";
/**
* The Home hero, composed per tier with a procurement-aware footer:
*
* - free → marketing welcome banner
* - subscribed → deployed-Editor status card
* - enterprise → deployed-Editor status card (chips hidden; the deal hero owns invite)
*
* The footer is the deal-status hero while a procurement deal is underway
* (procurement is a bolt-on to any tier), otherwise the setup checklist. The
* procurement takeover flow modals render alongside so the hero's actions can
* open them.
*/
export function HomeHero({ tier }: { tier: Tier }) {
const procurement = useProcurement();
const dealActive =
procurement.isLinked && procurement.started && !!procurement.data;
const footer = dealActive ? (
<ControlledDealStatusHero controller={procurement} />
) : (
<SetupChecklist />
);
return (
<>
{tier === "free" ? (
<WelcomeBanner footer={footer} />
) : (
<EditorStatusCard footer={footer} hideChips={tier === "enterprise"} />
)}
<ProcurementFlow controller={procurement} />
</>
);
}
@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import { NavItem } from "@app/ui";
import { useUI } from "@portal/contexts/UIContext";
import { useLink } from "@portal/contexts/LinkContext";
import { LinkIcon } from "@portal/components/icons";
/**
* Sidebar-footer link-account CTA. Only visible when the org is unlinked — once
* linked, the linked-instances row + plan badge already communicate the state,
* so a permanent footer button would be noise. Click → opens the login modal
* directly. The SaaS build shadows this file with a no-op: the signed-in account
* IS the SaaS account, so there is nothing to link.
*/
export function LinkAccountFooterItem() {
const { t } = useTranslation();
const { openLinkModal } = useUI();
const { linkState } = useLink();
if (linkState !== "unlinked") return null;
return (
<NavItem
id="account-link"
label={t("portal.shell.sidebar.linkAccount", "Link Stirling account")}
icon={<LinkIcon />}
onClick={() => openLinkModal()}
/>
);
}
@@ -0,0 +1,84 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
import { useUI } from "@portal/contexts/UIContext";
import { AppShell } from "@portal/components/AppShell";
import { AssistantButton } from "@portal/components/AssistantButton";
import { AssistantPanel } from "@portal/components/AssistantPanel";
import { SearchModal } from "@portal/components/SearchModal";
import { SettingsModal } from "@portal/components/SettingsModal";
import { ViewRouter } from "@portal/ViewRouter";
/**
* Global keyboard shortcuts. Lives below the UIProvider so it can dispatch into
* the overlay state. Currently just ⌘K / Ctrl+K to toggle the search palette.
*/
function GlobalShortcuts() {
const { toggleSearch, closeSearch } = useUI();
useEffect(() => {
function onKey(e: KeyboardEvent) {
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
if (isCmdK) {
e.preventDefault();
toggleSearch();
return;
}
if (e.key === "Escape") {
closeSearch();
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [toggleSearch, closeSearch]);
return null;
}
/** Bridges the Settings modal's open/close props to UIContext state. */
function SettingsHost() {
const { settingsOpen, settingsInitialSection, closeSettings } = useUI();
return (
<SettingsModal
open={settingsOpen}
onClose={closeSettings}
initialSection={settingsInitialSection}
/>
);
}
/**
* The routed view, wrapped in an error boundary so a single view crashing can't
* white-screen the portal (the shell + nav stay alive). Keyed by route so
* navigating to another section clears any error from the previous one.
*/
function RoutedContent() {
const { pathname } = useLocation();
return (
<ErrorBoundary key={pathname}>
<ViewRouter />
</ErrorBoundary>
);
}
/**
* The flavor-agnostic portal chrome: the shell (sidebar + header + routed view)
* plus the global overlays that every flavor shares. Requires only the Tier and
* UI contexts above it — both flavors provide those. Flavor-specific overlays
* (e.g. the self-hosted account-link modal) are mounted by PortalProviders, not
* here.
*/
export function PortalChrome() {
return (
<>
<GlobalShortcuts />
<AppShell>
<RoutedContent />
</AppShell>
<AssistantButton />
<AssistantPanel />
<SearchModal />
<SettingsHost />
</>
);
}
@@ -31,9 +31,8 @@ import {
PoliciesIcon,
InfrastructureIcon,
SparklesIcon,
LinkIcon,
} from "@portal/components/icons";
import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel";
import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings";
import "@portal/components/SettingsModal.css";
type SettingsSection =
@@ -150,11 +149,17 @@ export function SettingsModal({
{
title: t("portal.settings.groups.admin"),
items: [
{
key: "account-link",
label: t("portal.settings.sections.account-link"),
icon: <LinkIcon size={16} />,
},
// Account-link is a self-hosted-only section; the SaaS build shadows
// the seam to null, dropping the item entirely.
...(accountLinkSettings
? [
{
key: accountLinkSettings.navKey,
label: t(accountLinkSettings.labelKey),
icon: accountLinkSettings.icon,
},
]
: []),
{
key: "authentication",
label: t("portal.settings.sections.authentication"),
@@ -337,7 +342,9 @@ export function SettingsModal({
/>
)}
{section === "account-link" && <AccountLinkPanel />}
{section === "account-link" && accountLinkSettings && (
<accountLinkSettings.Body />
)}
</SettingsShell>
</Modal>
);
@@ -0,0 +1,188 @@
/* ──────────────────────────────────────────────────────────────────────── */
/* "Finish setting up" checklist (welcome-hero footer) */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-setup {
display: flex;
flex-direction: column;
}
.portal-setup__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6875rem 1.25rem 0.1875rem;
}
.portal-setup__eyebrow {
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-5);
}
/* Progress counter doubles as the dismiss affordance. */
.portal-setup__dismiss {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.1875rem 0.4375rem;
border: none;
border-radius: var(--radius-md);
background: transparent;
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-5);
cursor: pointer;
transition:
color var(--motion-fast),
background var(--motion-fast);
}
.portal-setup__dismiss:hover {
color: var(--color-text-2);
background: var(--color-bg-hover);
}
.portal-setup__list {
list-style: none;
margin: 0;
padding: 0;
}
.portal-setup__row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.875rem;
width: 100%;
padding: 0.875rem 1.25rem;
border: none;
border-top: 1px solid var(--color-border-light);
background: transparent;
text-align: left;
transition: background var(--motion-fast);
}
.portal-setup__item:first-child .portal-setup__row {
border-top: none;
}
.portal-setup__row.is-interactive {
cursor: pointer;
}
.portal-setup__row.is-interactive:hover {
background: var(--color-bg-hover);
}
.portal-setup__row:disabled {
cursor: default;
}
.portal-setup__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
background: var(--color-border-input);
}
.portal-setup__dot.is-done {
background: var(--color-green);
}
.portal-setup__text {
display: flex;
flex-direction: column;
min-width: 0;
}
.portal-setup__text strong {
font-size: 0.84375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-setup__text span {
margin-top: 0.0625rem;
font-size: 0.75rem;
line-height: 1.4;
color: var(--color-text-4);
}
.portal-setup__chip {
flex-shrink: 0;
padding: 0.125rem 0.5625rem;
border-radius: var(--radius-pill);
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-5);
background: var(--color-bg-hover);
}
.portal-setup__chip.is-done {
color: var(--color-green-dark);
background: var(--color-green-light);
}
/* Loading skeleton */
.portal-setup__row.is-loading {
border-top: 1px solid var(--color-border-light);
}
.portal-setup__skel {
height: 0.75rem;
width: 60%;
border-radius: var(--radius-sm);
background: var(--color-bg-muted);
animation: portalSetupPulse 1.2s ease-in-out infinite;
}
@keyframes portalSetupPulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
/* ── Enterprise upsell rung ── */
.portal-setup__enterprise {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.875rem 1.25rem;
border-top: 1px solid var(--color-border-light);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-blue) 5%, transparent) 0%,
transparent 55%
);
}
.portal-setup__enterprise-copy {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.portal-setup__enterprise-tag {
flex-shrink: 0;
padding: 0.1875rem 0.5625rem;
border-radius: var(--radius-md);
font-size: 0.59375rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-blue-dark);
background: var(--color-blue-light);
}
.portal-setup__enterprise-text {
margin: 0;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--color-text-3);
min-width: 0;
}
.portal-setup__enterprise-text strong {
color: var(--color-text-1);
font-weight: 700;
}
@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { SetupChecklist } from "@portal/components/SetupChecklist";
const meta: Meta<typeof SetupChecklist> = {
title: "Portal/Home/SetupChecklist",
component: SetupChecklist,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div
style={{
maxWidth: "60rem",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-xl)",
overflow: "hidden",
background: "var(--color-surface)",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SetupChecklist>;
/** Completion + counts derived from the live policies / sources MSW fixtures. */
export const Default: Story = {};
/** Slow policy/source fetch: shows the loading skeleton rows. */
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/policies", async () => {
await delay(100000);
return HttpResponse.json([]);
}),
],
},
},
};
/** A fresh workspace — no active policies or connected sources: every step reads "Not started". */
export const NotStarted: Story = {
parameters: {
msw: {
handlers: [
http.get("/api/v1/policies", () => HttpResponse.json([])),
http.get("/api/v1/policies/runs", () => HttpResponse.json([])),
http.get("/api/v1/sources", () =>
HttpResponse.json({ kpis: [], sources: [] }),
),
],
},
},
};
@@ -0,0 +1,218 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchPolicies, type PoliciesResponse } from "@portal/api/policies";
import { fetchSources, type SourcesResponse } from "@portal/api/sources";
import { CloseIcon } from "@portal/components/icons";
import "@portal/components/SetupChecklist.css";
/** Where the Stirling PDF Editor desktop app is downloaded. */
const EDITOR_DOWNLOAD_URL = "https://stirling.com/download";
/* ──────────────────────────────────────────────────────────────────────── */
/* Enterprise upsell rung */
/* ──────────────────────────────────────────────────────────────────────── */
/**
* Enterprise on-ramp rung. The CTA differs by tier: free orgs start a guided
* trial, subscribed (paying) orgs jump straight to a quote — both land in the
* procurement flow.
*/
function EnterpriseRung({ paying }: { paying: boolean }) {
const { t } = useTranslation();
const { setActiveView } = useView();
return (
<div className="portal-setup__enterprise">
<div className="portal-setup__enterprise-copy">
<span className="portal-setup__enterprise-tag">
{t("portal.home.onboarding.enterprise.tag")}
</span>
<p className="portal-setup__enterprise-text">
<strong>{t("portal.home.onboarding.enterprise.lead")}</strong>{" "}
{t("portal.home.onboarding.enterprise.body")}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setActiveView("procurement")}
rightSection={<span aria-hidden>→</span>}
>
{t(
paying
? "portal.home.onboarding.enterprise.ctaQuote"
: "portal.home.onboarding.enterprise.cta",
)}
</Button>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* "Finish setting up" checklist */
/* ──────────────────────────────────────────────────────────────────────── */
type StepAction =
| { kind: "external"; href: string }
| { kind: "navigate"; view: ViewId };
interface Step {
id: string;
title: string;
blurb: string;
done: boolean;
/** Status chip shown once the step is done. */
chip: string;
action: StepAction;
}
/**
* Getting-started checklist, rendered as the attached footer of the home hero
* (welcome banner on free, deployed-Editor card on subscribed). Steps are a
* fixed do-this-then-that sequence with translatable copy; the completion
* state and counts are derived live from the org's real policies and sources
* (the same data the Policies / Sources pages show). The header doubles as a
* dismiss control; the Enterprise rung persists regardless.
*/
export function SetupChecklist() {
const { t } = useTranslation();
const { tier } = useTier();
const { setActiveView } = useView();
const [dismissed, setDismissed] = useState(false);
const state = useAsync<[PoliciesResponse, SourcesResponse]>(
() => Promise.all([fetchPolicies(), fetchSources()]),
[],
);
const { data, loading } = state;
const steps = useMemo<Step[]>(() => {
const [policies, sources] = data ?? [];
const active = policies?.summary.active ?? 0;
const recommended = Math.max(
0,
(policies?.summary.categories ?? 0) - active,
);
// A source counts as connected once it's set up and not disabled.
const connected =
sources?.sources.filter((s) => s.status !== "disabled").length ?? 0;
return [
{
id: "editor",
title: t("portal.home.onboarding.steps.editor.title"),
blurb: t("portal.home.onboarding.steps.editor.blurb"),
// "Deploy the desktop app" has no server signal — always an open task.
done: false,
chip: "",
action: { kind: "external", href: EDITOR_DOWNLOAD_URL },
},
{
id: "policies",
title: t("portal.home.onboarding.steps.policies.title"),
blurb: t("portal.home.onboarding.steps.policies.blurb", {
active,
recommended,
}),
done: active > 0,
chip: t("portal.home.onboarding.steps.policies.chip", { active }),
action: { kind: "navigate", view: "policies" },
},
{
id: "sources",
title: t("portal.home.onboarding.steps.sources.title"),
blurb: t("portal.home.onboarding.steps.sources.blurb", { connected }),
done: connected > 0,
chip: t("portal.home.onboarding.steps.sources.chip", { connected }),
action: { kind: "navigate", view: "sources" },
},
];
}, [data, t]);
const doneCount = steps.filter((s) => s.done).length;
function activate(step: Step) {
if (step.done) return;
if (step.action.kind === "external") {
window.open(step.action.href, "_blank", "noopener,noreferrer");
} else {
setActiveView(step.action.view);
}
}
return (
<div className="portal-setup">
{loading && (
<div className="portal-setup__list" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="portal-setup__row is-loading">
<span className="portal-setup__dot" />
<div className="portal-setup__text">
<div className="portal-setup__skel" />
</div>
</div>
))}
</div>
)}
{!loading && !dismissed && (
<>
<div className="portal-setup__head">
<span className="portal-setup__eyebrow">
{t("portal.home.onboarding.title")}
</span>
<button
type="button"
className="portal-setup__dismiss"
onClick={() => setDismissed(true)}
title={t("portal.home.onboarding.dismiss")}
>
{t("portal.home.onboarding.progress", {
done: doneCount,
total: steps.length,
})}
<CloseIcon size={13} />
</button>
</div>
<ol className="portal-setup__list">
{steps.map((s) => (
<li key={s.id} className="portal-setup__item">
<button
type="button"
className={
"portal-setup__row" +
(s.done ? " is-done" : " is-interactive")
}
onClick={() => activate(s)}
disabled={s.done}
>
<span
className={"portal-setup__dot" + (s.done ? " is-done" : "")}
aria-hidden
/>
<span className="portal-setup__text">
<strong>{s.title}</strong>
<span>{s.blurb}</span>
</span>
<span
className={
"portal-setup__chip" + (s.done ? " is-done" : "")
}
>
{s.done ? s.chip : t("portal.home.onboarding.notStarted")}
</span>
</button>
</li>
))}
</ol>
</>
)}
<EnterpriseRung paying={tier !== "free"} />
</div>
);
}
@@ -40,35 +40,12 @@
white-space: nowrap;
}
/* App switcher (down-arrow → Portal / Editor) */
/* App switcher (down-arrow → Portal / Editor); button and menu styling live
with the shared AppSwitch element. */
.portal-sidebar__app-switch {
margin-left: auto;
display: flex;
}
.portal-sidebar__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);
}
.portal-sidebar__app-switch-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-2);
}
.portal-sidebar__app-icon {
width: 1rem;
height: 1.0625rem;
display: block;
}
/* Nav body */
.portal-sidebar__nav {
@@ -1,13 +1,15 @@
import { Button, Dropdown, NavItem } from "@app/ui";
import { NavItem } from "@app/ui";
import { AppSwitch } from "@app/components/shared/AppSwitch";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import { useTier } from "@portal/contexts/TierContext";
import { useTheme } from "@portal/contexts/ThemeContext";
import { useUI } from "@portal/contexts/UIContext";
import { useLink } from "@portal/contexts/LinkContext";
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
import { EDITOR_URL } from "@portal/auth/editorUrl";
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
import {
@@ -20,10 +22,8 @@ import {
ComponentsIcon,
InfrastructureIcon,
UsageIcon,
LinkIcon,
DocsIcon,
SettingsIcon,
ChevronDownIcon,
} from "@portal/components/icons";
import "@portal/components/Sidebar.css";
@@ -49,27 +49,6 @@ const GROUP_PLATFORM: NavEntry[] = [
{ id: "docs", icon: <DocsIcon /> },
];
/**
* Sidebar-footer link-account CTA. Only visible when the org is unlinked — once
* linked, the linked-instances row + plan badge already communicate the state,
* so a permanent footer button would be noise. Click → opens the login modal
* directly.
*/
function LinkAccountFooterItem() {
const { t } = useTranslation();
const { openLinkModal } = useUI();
const { linkState } = useLink();
if (linkState !== "unlinked") return null;
return (
<NavItem
id="account-link"
label={t("portal.shell.sidebar.linkAccount", "Link Stirling account")}
icon={<LinkIcon />}
onClick={() => openLinkModal()}
/>
);
}
function UsageFooter() {
const { tier } = useTier();
const { t } = useTranslation();
@@ -136,6 +115,15 @@ export function Sidebar() {
const { theme } = useTheme();
const { openSettings } = useUI();
const { t } = useTranslation();
const navigate = useNavigate();
// Editor and portal are one SPA when the editor serves this origin's root, so
// the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup)
// needs a full page load.
const goToEditor = () => {
if (EDITOR_IS_SAME_APP) navigate("/");
else window.location.href = EDITOR_URL;
};
// Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into
// a takeover modal (matching the marketing prototype).
@@ -170,45 +158,12 @@ export function Sidebar() {
</span>
</span>
<Dropdown.Root align="end" className="portal-sidebar__app-switch">
<Dropdown.Trigger>
<Button
variant="tertiary"
className="portal-sidebar__app-switch-btn"
aria-label={t("portal.shell.sidebar.switchApp")}
>
<ChevronDownIcon size={14} />
</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="11rem">
<Dropdown.Item
active
leading={
<img
className="portal-sidebar__app-icon"
src={theme === "dark" ? markDark : markLight}
alt=""
/>
}
>
{t("portal.shell.sidebar.appProcessor")}
</Dropdown.Item>
<Dropdown.Item
onSelect={() => {
window.location.href = EDITOR_URL;
}}
leading={
<img
className="portal-sidebar__app-icon"
src={theme === "dark" ? markDark : markLight}
alt=""
/>
}
>
{t("portal.shell.sidebar.appEditor")}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Root>
<AppSwitch
className="portal-sidebar__app-switch"
current="processor"
theme={theme}
onSwitch={goToEditor}
/>
</div>
<nav className="portal-sidebar__nav">
@@ -0,0 +1,461 @@
/* ──────────────────────────────────────────────────────────────────────── */
/* Free-tier welcome hero */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-welcome {
border-radius: var(--radius-xl);
border: 1px solid var(--color-blue-border);
overflow: hidden;
isolation: isolate;
}
.portal-welcome__body {
position: relative;
display: flex;
align-items: stretch;
min-height: 22.5rem;
background: linear-gradient(135deg, #eaf2fb 0%, #eef3fb 50%, #f4f0fa 100%);
}
[data-theme="dark"] .portal-welcome__body {
background: linear-gradient(135deg, #0e0e10 0%, #121214 50%, #161618 100%);
}
/* ── Flowing gradient canvas: three radial blobs drifting at different
speeds for a slow, organic color flow. Purely decorative. ── */
.portal-welcome__canvas {
position: absolute;
inset: -40%;
width: 180%;
height: 180%;
filter: blur(3.75rem);
pointer-events: none;
z-index: 0;
}
.portal-welcome__blob {
position: absolute;
border-radius: 50%;
}
.portal-welcome__blob--1 {
width: 60%;
height: 70%;
top: -10%;
left: 30%;
background: radial-gradient(
circle,
rgba(59, 130, 246, 0.35) 0%,
rgba(99, 102, 241, 0.2) 40%,
transparent 70%
);
animation: portalHeroBlob1 5s ease-in-out infinite;
}
.portal-welcome__blob--2 {
width: 55%;
height: 60%;
top: 10%;
right: -15%;
background: radial-gradient(
circle,
rgba(139, 92, 246, 0.4) 0%,
rgba(192, 132, 252, 0.2) 40%,
transparent 70%
);
animation: portalHeroBlob2 7s ease-in-out infinite;
}
.portal-welcome__blob--3 {
width: 50%;
height: 55%;
bottom: -15%;
left: 35%;
background: radial-gradient(
circle,
rgba(59, 130, 246, 0.25) 0%,
rgba(14, 165, 233, 0.15) 40%,
transparent 70%
);
animation: portalHeroBlob3 9s ease-in-out infinite;
}
[data-theme="dark"] .portal-welcome__blob--1 {
left: 20%;
background: radial-gradient(
circle,
rgba(96, 165, 250, 0.4) 0%,
rgba(99, 102, 241, 0.25) 40%,
transparent 70%
);
}
[data-theme="dark"] .portal-welcome__blob--2 {
background: radial-gradient(
circle,
rgba(167, 139, 250, 0.45) 0%,
rgba(192, 132, 252, 0.25) 40%,
transparent 70%
);
}
[data-theme="dark"] .portal-welcome__blob--3 {
left: 20%;
background: radial-gradient(
circle,
rgba(96, 165, 250, 0.3) 0%,
rgba(56, 189, 248, 0.18) 40%,
transparent 70%
);
}
@keyframes portalHeroBlob1 {
0% {
transform: translate(0%, 0%) scale(1);
}
33% {
transform: translate(18%, -25%) scale(1.15);
}
66% {
transform: translate(-10%, 18%) scale(0.9);
}
100% {
transform: translate(0%, 0%) scale(1);
}
}
@keyframes portalHeroBlob2 {
0% {
transform: translate(0%, 0%) scale(1);
}
33% {
transform: translate(-20%, 15%) scale(1.1);
}
66% {
transform: translate(15%, -20%) scale(1.15);
}
100% {
transform: translate(0%, 0%) scale(1);
}
}
@keyframes portalHeroBlob3 {
0% {
transform: translate(0%, 0%) scale(1.05);
}
33% {
transform: translate(22%, 10%) scale(0.9);
}
66% {
transform: translate(-15%, -15%) scale(1.15);
}
100% {
transform: translate(0%, 0%) scale(1.05);
}
}
@media (prefers-reduced-motion: reduce) {
.portal-welcome__blob {
animation: none;
}
}
/* ── Left column: copy + CTAs ── */
.portal-welcome__text {
position: relative;
z-index: 1;
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: 2.75rem 0 2.75rem 3rem;
}
.portal-welcome__badge {
align-self: flex-start;
margin-bottom: 1rem;
padding: 0.3125rem 0.875rem;
border-radius: var(--radius-pill);
border: 1px solid var(--color-blue-border);
background: var(--color-surface);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-blue);
}
.portal-welcome__title {
margin: 0 0 1.125rem;
font-size: 2.25rem;
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.025em;
color: var(--color-text-1);
}
.portal-welcome__title-accent {
color: var(--color-blue);
}
.portal-welcome__sub {
margin: 0 0 1.75rem;
max-width: 32.5rem;
font-size: 1rem;
line-height: 1.6;
color: var(--color-text-3);
}
.portal-welcome__cta {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.portal-welcome__perks {
margin: 1.375rem 0 0;
font-size: 0.78125rem;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--color-text-4);
}
/* ── Right column: editor-card visual ── */
.portal-welcome__visual {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: flex-end;
flex: none;
padding-left: 1.5rem;
}
@media (max-width: 60rem) {
.portal-welcome__visual {
display: none;
}
.portal-welcome__text {
padding: 2.5rem 2rem;
}
}
/* ── Dark editor card ── */
.portal-welcome__editor {
align-self: flex-end;
width: 38.75rem;
max-width: 100%;
margin-right: 2.5rem;
min-height: 18.75rem;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 0.75rem 0.75rem 0 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: none;
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(0.75rem);
box-shadow:
0 0.5rem 2rem rgba(0, 0, 0, 0.25),
0 0.125rem 0.5rem rgba(0, 0, 0, 0.15);
}
.portal-welcome__editor-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.portal-welcome__editor-dots {
display: flex;
gap: 0.375rem;
flex-shrink: 0;
}
.portal-welcome__editor-dots span {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
}
.portal-welcome__editor-dots span:nth-child(1) {
background: #ef4444;
}
.portal-welcome__editor-dots span:nth-child(2) {
background: #f59e0b;
}
.portal-welcome__editor-dots span:nth-child(3) {
background: #22c55e;
}
.portal-welcome__editor-file {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.6);
}
.portal-welcome__editor-name {
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-welcome__editor-sep {
color: rgba(255, 255, 255, 0.35);
flex-shrink: 0;
}
.portal-welcome__editor-pages {
color: rgba(255, 255, 255, 0.55);
flex-shrink: 0;
}
.portal-welcome__editor-page {
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.55);
flex-shrink: 0;
}
.portal-welcome__editor-body {
display: flex;
align-items: stretch;
flex: 1;
min-height: 0;
}
.portal-welcome__rail {
width: 2.5rem;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0;
color: rgba(255, 255, 255, 0.4);
}
.portal-welcome__editor-body > .portal-welcome__rail:first-child {
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
.portal-welcome__editor-body > .portal-welcome__rail:last-child {
border-left: 1px solid rgba(255, 255, 255, 0.06);
}
.portal-welcome__rail-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: 0.3125rem;
}
.portal-welcome__rail-icon:nth-child(2) {
background: rgba(96, 165, 250, 0.18);
color: rgba(147, 197, 253, 0.95);
}
/* ── White document preview ── */
.portal-welcome__doc {
flex: 1;
min-width: 0;
margin: 0.75rem 1rem 0;
padding: 0.875rem 1.125rem;
border-radius: 0.25rem 0.25rem 0 0;
background: #ffffff;
color: #0f172a;
box-shadow: 0 0.25rem 1.125rem rgba(0, 0, 0, 0.22);
}
.portal-welcome__doc-eyebrow {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.5625rem;
font-weight: 700;
letter-spacing: 0.08em;
color: #dc2626;
}
.portal-welcome__doc-critical {
padding: 0.1875rem 0.5625rem;
border-radius: 0.1875rem;
background: #fee2e2;
letter-spacing: 0.06em;
}
.portal-welcome__doc-title {
margin-bottom: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.3;
color: #0f172a;
}
.portal-welcome__doc-divider {
border-top: 1px solid #e8e6e1;
margin-bottom: 0.625rem;
}
.portal-welcome__doc-meta {
display: flex;
gap: 1.5rem;
margin-bottom: 0.75rem;
font-size: 0.625rem;
color: #0f172a;
font-weight: 600;
}
.portal-welcome__doc-meta-key {
color: #9b9a97;
font-weight: 400;
}
.portal-welcome__doc-heading {
margin-bottom: 0.375rem;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.06em;
color: #0f172a;
}
.portal-welcome__doc-body {
margin: 0 0 0.75rem;
font-size: 0.625rem;
line-height: 1.5;
color: #6b6b6b;
}
.portal-welcome__doc-finding {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.4375rem 0.75rem;
border-radius: 0.1875rem;
font-size: 0.625rem;
font-weight: 500;
}
.portal-welcome__doc-finding--critical {
margin-bottom: 0.3125rem;
background: #fee2e2;
color: #7f1d1d;
}
.portal-welcome__doc-finding--high {
background: #fef3c7;
color: #92400e;
}
.portal-welcome__doc-dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
flex-shrink: 0;
}
.portal-welcome__doc-finding--critical .portal-welcome__doc-dot {
background: #dc2626;
}
.portal-welcome__doc-finding--high .portal-welcome__doc-dot {
background: #d97706;
}
/* ── Attached footer strip (setup checklist) ── */
.portal-welcome__footer {
position: relative;
z-index: 2;
border-top: 1px solid var(--color-border-light);
background: var(--color-surface);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WelcomeBanner } from "@portal/components/WelcomeBanner";
import { SetupChecklist } from "@portal/components/SetupChecklist";
const meta: Meta<typeof WelcomeBanner> = {
title: "Portal/Home/WelcomeBanner",
component: WelcomeBanner,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "72rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof WelcomeBanner>;
/** The hero on its own, no attached footer. */
export const Default: Story = {};
/** The hero as it renders on the free-tier home: the "Finish setting up"
* checklist attached as the footer strip. */
export const WithSetupChecklist: Story = {
args: {
footer: <SetupChecklist />,
},
};
@@ -0,0 +1,183 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
import {
DownloadIcon,
UsersIcon,
EditorIcon,
SearchIcon,
SourcesIcon,
PoliciesIcon,
SparklesIcon,
ComponentsIcon,
DocumentsIcon,
SettingsIcon,
} from "@portal/components/icons";
import "@portal/components/WelcomeBanner.css";
/* ──────────────────────────────────────────────────────────────────────── */
/* Editor-card visual */
/* */
/* A decorative mock of the deployed PDF Editor reviewing a security */
/* report. Purely presentational — hidden from assistive tech. */
/* ──────────────────────────────────────────────────────────────────────── */
const RAIL_ICONS = [
SearchIcon,
SourcesIcon,
PoliciesIcon,
EditorIcon,
ComponentsIcon,
SparklesIcon,
DocumentsIcon,
SettingsIcon,
] as const;
function EditorRail() {
return (
<div className="portal-welcome__rail" aria-hidden>
{RAIL_ICONS.map((Icon, i) => (
<span key={i} className="portal-welcome__rail-icon">
<Icon size={13} />
</span>
))}
</div>
);
}
function WelcomeEditorCard() {
return (
<div className="portal-welcome__editor" aria-hidden>
<div className="portal-welcome__editor-bar">
<div className="portal-welcome__editor-dots">
<span />
<span />
<span />
</div>
<div className="portal-welcome__editor-file">
<EditorIcon size={12} />
<span className="portal-welcome__editor-name">
Vulnerability Assessment Report CVE-2026-1847.pdf
</span>
<span className="portal-welcome__editor-sep">·</span>
<span className="portal-welcome__editor-pages">18 pages</span>
</div>
<span className="portal-welcome__editor-page">1 / 18</span>
</div>
<div className="portal-welcome__editor-body">
<EditorRail />
<div className="portal-welcome__doc">
<div className="portal-welcome__doc-eyebrow">
<span>SECURITY ASSESSMENT</span>
<span className="portal-welcome__doc-critical">CRITICAL</span>
</div>
<div className="portal-welcome__doc-title">
Vulnerability Assessment Report CVE-2026-1847
</div>
<div className="portal-welcome__doc-divider" />
<div className="portal-welcome__doc-meta">
<span>
<span className="portal-welcome__doc-meta-key">
Assessment Date:{" "}
</span>
February 2026
</span>
<span>
<span className="portal-welcome__doc-meta-key">
Classification:{" "}
</span>
FOUO
</span>
</div>
<div className="portal-welcome__doc-heading">EXECUTIVE SUMMARY</div>
<p className="portal-welcome__doc-body">
This assessment identifies critical vulnerabilities in the target
environment and provides actionable remediation guidance prioritized
by risk severity.
</p>
<div className="portal-welcome__doc-heading">KEY FINDINGS</div>
<div className="portal-welcome__doc-finding portal-welcome__doc-finding--critical">
<span className="portal-welcome__doc-dot" />3 Critical
vulnerabilities identified
</div>
<div className="portal-welcome__doc-finding portal-welcome__doc-finding--high">
<span className="portal-welcome__doc-dot" />7 High-severity issues
requiring attention
</div>
</div>
<EditorRail />
</div>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Welcome banner (free-tier hero) */
/* ──────────────────────────────────────────────────────────────────────── */
interface WelcomeBannerProps {
/**
* Rendered as an attached footer strip inside the banner card (e.g. the
* "Finish setting up" checklist). Kept as a slot so the hero stays a pure
* presentational shell.
*/
footer?: ReactNode;
}
export function WelcomeBanner({ footer }: WelcomeBannerProps) {
const { t } = useTranslation();
const { setActiveView } = useView();
return (
<section
className="portal-welcome"
aria-label={t("portal.welcome.ariaLabel")}
>
<div className="portal-welcome__body">
<div className="portal-welcome__canvas" aria-hidden>
<div className="portal-welcome__blob portal-welcome__blob--1" />
<div className="portal-welcome__blob portal-welcome__blob--2" />
<div className="portal-welcome__blob portal-welcome__blob--3" />
</div>
<div className="portal-welcome__text">
<span className="portal-welcome__badge">
{t("portal.welcome.badge")}
</span>
<h1 className="portal-welcome__title">
{t("portal.welcome.title")}{" "}
<span className="portal-welcome__title-accent">
{t("portal.welcome.titleAccent")}
</span>
</h1>
<p className="portal-welcome__sub">{t("portal.welcome.subtitle")}</p>
<div className="portal-welcome__cta">
<Button
variant="primary"
leftSection={<DownloadIcon size={15} />}
onClick={() => setActiveView("editor")}
>
{t("portal.welcome.installEditor")}
</Button>
<Button
variant="secondary"
leftSection={<UsersIcon size={15} />}
onClick={() => setActiveView("users")}
>
{t("portal.welcome.inviteTeammates")}
</Button>
</div>
<p className="portal-welcome__perks">{t("portal.welcome.perks")}</p>
</div>
<div className="portal-welcome__visual">
<WelcomeEditorCard />
</div>
</div>
{footer && <div className="portal-welcome__footer">{footer}</div>}
</section>
);
}
@@ -1,234 +0,0 @@
.portal-carousel {
position: relative;
overflow: hidden;
padding: 2rem;
border-radius: var(--radius-xl);
background: var(--grad-banner);
border: 1px solid var(--color-border);
isolation: isolate;
}
.portal-carousel__inner {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
gap: 2rem;
align-items: center;
animation: fadeInUp var(--motion-enter) both;
}
@media (max-width: 50rem) {
.portal-carousel__inner {
grid-template-columns: 1fr;
}
}
.portal-carousel__text {
min-width: 0;
}
.portal-carousel__eyebrow {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-blue);
margin-bottom: 0.5rem;
}
.portal-carousel__title {
margin: 0 0 0.75rem;
font-size: 1.75rem;
font-weight: 700;
line-height: 1.15;
color: var(--color-text-1);
}
.portal-carousel__sub {
margin: 0 0 1.25rem;
font-size: 0.9375rem;
line-height: 1.55;
color: var(--color-text-3);
max-width: 36rem;
}
.portal-carousel__cta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Ornament container */
.portal-carousel__ornament {
display: flex;
align-items: center;
justify-content: center;
}
/* Doc card ornament (slide 0) */
.portal-carousel__doc {
width: 100%;
padding: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.portal-carousel__doc-title {
margin-top: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-carousel__doc-sub {
margin-top: 0.125rem;
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-carousel__doc-meta {
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--color-text-4);
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
/* Code ornament (slide 1) */
.portal-carousel__code {
width: 100%;
margin: 0;
padding: 0.875rem 1rem;
background: var(--code-bg);
color: var(--code-text);
border: 1px solid var(--code-border);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
box-shadow: var(--shadow-lg);
}
.portal-carousel__code-method {
color: var(--code-fn);
font-weight: 600;
}
.portal-carousel__code-path {
color: var(--code-keyword);
}
.portal-carousel__code-key {
color: var(--code-property);
}
.portal-carousel__code-string {
color: var(--code-string);
}
/* Eval ornament (slide 2) */
.portal-carousel__eval {
width: 100%;
padding: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.portal-carousel__eval-head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.portal-carousel__eval-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-carousel__eval-score {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-green);
}
.portal-carousel__eval-sub {
font-size: 0.75rem;
color: var(--color-text-4);
margin-top: 0.125rem;
}
.portal-carousel__eval-bar {
margin-top: 0.75rem;
height: 0.375rem;
background: var(--color-bg-muted);
border-radius: var(--radius-pill);
overflow: hidden;
}
.portal-carousel__eval-fill {
height: 100%;
background: var(--grad-green-btn);
}
.portal-carousel__eval-meta {
margin-top: 0.625rem;
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: var(--color-text-4);
}
/* Decoration blobs */
.portal-carousel__decor {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.portal-carousel__blob {
position: absolute;
border-radius: 50%;
filter: blur(2.5rem);
}
.portal-carousel__blob--1 {
top: -3rem;
right: -2rem;
width: 16rem;
height: 16rem;
background: radial-gradient(circle, var(--color-purple), transparent 70%);
opacity: 0.4;
}
.portal-carousel__blob--2 {
bottom: -4rem;
right: 8rem;
width: 12rem;
height: 12rem;
background: radial-gradient(circle, var(--color-blue), transparent 70%);
opacity: 0.3;
}
/* Pagination dots */
.portal-carousel__dots {
position: absolute;
bottom: 0.875rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.375rem;
z-index: 2;
}
.portal-carousel__dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--color-text-5);
opacity: 0.4;
transition:
opacity var(--motion-fast),
background var(--motion-fast),
width var(--motion-fast);
}
.portal-carousel__dot:hover {
opacity: 0.7;
}
.portal-carousel__dot.is-active {
width: 1rem;
border-radius: var(--radius-pill);
background: var(--color-blue);
opacity: 1;
}
@@ -1,20 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WelcomeCarousel } from "@portal/components/WelcomeCarousel";
const meta: Meta<typeof WelcomeCarousel> = {
title: "Portal/Home/WelcomeCarousel",
component: WelcomeCarousel,
args: { onTryOp: () => console.log("try op") },
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "64rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof WelcomeCarousel>;
export const AutoRotating: Story = {};
@@ -1,232 +0,0 @@
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button, StatusBadge } from "@app/ui";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import "@portal/components/WelcomeCarousel.css";
type SlideAction =
| { labelKey: string; target: ViewId }
| { labelKey: string; action: "try-op" };
interface Slide {
id: string;
durationMs: number;
primary: SlideAction;
secondary: SlideAction;
ornament: ReactNode;
}
function EditorOrnament() {
const { t } = useTranslation();
return (
<div className="portal-carousel__doc">
<StatusBadge tone="danger" size="sm" pulse>
{t("portal.welcome.ornament.editor.critical")}
</StatusBadge>
<div className="portal-carousel__doc-title">
Vulnerability Assessment Report
</div>
<div className="portal-carousel__doc-sub">CVE-2026-1847 · 12 pages</div>
<div className="portal-carousel__doc-meta">
<span>{t("portal.welcome.ornament.editor.signed")}</span>
<span>·</span>
<span>{t("portal.welcome.ornament.editor.ocrClean")}</span>
<span>·</span>
<span>{t("portal.welcome.ornament.editor.schemaMatch")}</span>
</div>
</div>
);
}
function PlatformOrnament() {
return (
<pre className="portal-carousel__code">
<span className="portal-carousel__code-method">POST</span>{" "}
<span className="portal-carousel__code-path">/v1/secure</span>
{"\n"}
<span className="portal-carousel__code-key">Authorization</span>:{" "}
<span className="portal-carousel__code-string">Bearer sk_live_a3f8…</span>
{"\n"}
<span className="portal-carousel__code-key">file</span>:{" "}
<span className="portal-carousel__code-string">
federal_CUI_contract.pdf
</span>
{"\n"}
<span className="portal-carousel__code-key">pipeline</span>:{" "}
<span className="portal-carousel__code-string">cui-redact-v2</span>
</pre>
);
}
function AgentOrnament() {
return (
<div className="portal-carousel__eval">
<div className="portal-carousel__eval-head">
<span className="portal-carousel__eval-title">KYC Processor</span>
<span className="portal-carousel__eval-score">94%</span>
</div>
<div className="portal-carousel__eval-sub">
Eval pass rate · 28 test cases
</div>
<div className="portal-carousel__eval-bar">
<div
className="portal-carousel__eval-fill"
style={{ width: "94%" }}
aria-hidden
/>
</div>
<div className="portal-carousel__eval-meta">
<span>26 passed</span>
<span>2 review</span>
<span>MCP · Claude</span>
</div>
</div>
);
}
const SLIDES: Slide[] = [
{
id: "editor",
durationMs: 12000,
primary: {
labelKey: "portal.welcome.slides.editor.primary",
target: "editor",
},
secondary: {
labelKey: "portal.welcome.slides.editor.secondary",
target: "editor",
},
ornament: <EditorOrnament />,
},
{
id: "platform",
durationMs: 8000,
primary: {
labelKey: "portal.welcome.slides.platform.primary",
action: "try-op",
},
secondary: {
labelKey: "portal.welcome.slides.platform.secondary",
target: "infrastructure",
},
ornament: <PlatformOrnament />,
},
{
id: "agents",
durationMs: 8000,
primary: {
labelKey: "portal.welcome.slides.agents.primary",
target: "sources",
},
secondary: {
labelKey: "portal.welcome.slides.agents.secondary",
target: "docs",
},
ornament: <AgentOrnament />,
},
];
interface WelcomeCarouselProps {
/** Called when a slide CTA wants to invoke the single-op runner. */
onTryOp: () => void;
}
export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
const { t } = useTranslation();
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const { setActiveView } = useView();
function runAction(act: SlideAction) {
if ("action" in act && act.action === "try-op") {
onTryOp();
} else if ("target" in act) {
setActiveView(act.target);
}
}
useEffect(() => {
if (paused) return;
const duration = SLIDES[index].durationMs;
const t = window.setTimeout(() => {
setIndex((i) => (i + 1) % SLIDES.length);
}, duration);
return () => window.clearTimeout(t);
}, [index, paused]);
const slide = SLIDES[index];
return (
<section
className="portal-carousel"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={(e) => {
// onBlur bubbles from children; only unpause when focus actually
// leaves the carousel, not when tabbing between the dot buttons.
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setPaused(false);
}
}}
aria-label={t("portal.welcome.ariaLabel")}
aria-roledescription="carousel"
>
<div className="portal-carousel__inner" key={slide.id}>
<div className="portal-carousel__text">
<div className="portal-carousel__eyebrow">
{t(`portal.welcome.slides.${slide.id}.eyebrow`)}
</div>
<h1 className="portal-carousel__title">
{t(`portal.welcome.slides.${slide.id}.title`)}
</h1>
<p className="portal-carousel__sub">
{t(`portal.welcome.slides.${slide.id}.sub`)}
</p>
<div className="portal-carousel__cta">
<Button
onClick={() => runAction(slide.primary)}
rightSection={<span aria-hidden>→</span>}
>
{t(slide.primary.labelKey)}
</Button>
<Button
variant="secondary"
onClick={() => runAction(slide.secondary)}
>
{t(slide.secondary.labelKey)}
</Button>
</div>
</div>
<div className="portal-carousel__ornament">{slide.ornament}</div>
</div>
<div className="portal-carousel__decor" aria-hidden>
<div className="portal-carousel__blob portal-carousel__blob--1" />
<div className="portal-carousel__blob portal-carousel__blob--2" />
</div>
<div
className="portal-carousel__dots"
role="group"
aria-label={t("portal.welcome.pagination")}
>
{SLIDES.map((s, i) => (
<Button
key={s.id}
variant="tertiary"
aria-current={i === index ? "true" : undefined}
aria-label={t("portal.welcome.slideLabel", {
number: i + 1,
title: t(`portal.welcome.slides.${s.id}.title`),
})}
className={
"portal-carousel__dot" + (i === index ? " is-active" : "")
}
onClick={() => setIndex(i)}
/>
))}
</div>
</section>
);
}
@@ -49,7 +49,7 @@ export function LinkAccountCard({ link }: Props) {
)}
>
{t("portal.accountLink.card.loginNotConfigured.before", "Set")}{" "}
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
<code>VITE_SUPABASE_URL</code>{" "}
{t(
"portal.accountLink.card.loginNotConfigured.after",
"to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog.",
@@ -96,9 +96,9 @@ export function LinkAccountModal({
)}
>
{t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "}
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
<code>VITE_SUPABASE_URL</code>{" "}
{t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "}
<code>VITE_SAAS_SUPABASE_ANON_KEY</code>{" "}
<code>VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY</code>{" "}
{t(
"portal.accountLink.modal.loginNotConfigured.after",
"to enable in-app linking against the hosted Stirling account.",
@@ -36,10 +36,7 @@ export function EnterpriseUpsell({ bare = false }: Props) {
</p>
</div>
<Button size="sm" onClick={() => setActiveView("procurement")}>
{t(
"portal.billing.enterpriseUpsell.cta",
"Build your Enterprise quote",
)}
{t("portal.billing.enterpriseUpsell.cta", "Explore Enterprise")}
</Button>
</div>
</>
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
const linkState = { isLinked: false };
vi.mock("@portal/contexts/LinkContext", () => ({
useLink: () => linkState,
useApplyLinkFacts: () => vi.fn(),
}));
vi.mock("@portal/contexts/UIContext", () => ({
useUI: () => ({ openLinkModal: vi.fn() }),
}));
vi.mock("@portal/components/billing/LinkAccountPrompt", () => ({
LinkAccountPrompt: () => <div data-testid="link-prompt" />,
}));
vi.mock("@portal/views/Usage", () => ({
Usage: () => <div data-testid="usage" />,
}));
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
describe("PortalBillingGate — self-hosted", () => {
beforeEach(() => {
linkState.isLinked = false;
});
it("shows the link prompt when unlinked (billing gated on link)", () => {
linkState.isLinked = false;
render(<PortalBillingGate />);
expect(screen.getByTestId("link-prompt")).toBeInTheDocument();
expect(screen.queryByTestId("usage")).not.toBeInTheDocument();
});
it("renders the Usage page once linked", () => {
linkState.isLinked = true;
render(<PortalBillingGate />);
expect(screen.getByTestId("usage")).toBeInTheDocument();
expect(screen.queryByTestId("link-prompt")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,32 @@
import { useCallback } from "react";
import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext";
import { useUI } from "@portal/contexts/UIContext";
import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt";
import { Usage } from "@portal/views/Usage";
import type { Wallet } from "@portal/api/billing";
/**
* Billing access gate — the seam the SaaS build overrides.
*
* <p>Self-hosted (this base): billing only makes sense once the instance has
* linked its SaaS account, so gate on link state — unlinked shows the link prompt;
* linked renders the (flavor-agnostic) Usage page and maps its callbacks onto the
* link/tier dimension: the wallet's subscription status refines the plan/tier
* badge, and a lapsed SaaS session re-opens the account-link re-auth. This keeps
* the "link" concept entirely out of the Usage page. The SaaS build shadows this
* with a passthrough — there is no linking there.
*/
export function PortalBillingGate() {
const { isLinked } = useLink();
const applyLinkFacts = useApplyLinkFacts();
const { openLinkModal } = useUI();
const onWalletLoaded = useCallback(
(w: Wallet) => applyLinkFacts(true, w.status === "subscribed"),
[applyLinkFacts],
);
const onReauth = useCallback(() => openLinkModal("reauth"), [openLinkModal]);
if (!isLinked) return <LinkAccountPrompt />;
return <Usage onWalletLoaded={onWalletLoaded} onReauth={onReauth} />;
}
@@ -254,6 +254,16 @@ export function ProcurementIcon(props: IconProps) {
);
}
export function DownloadIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</Svg>
);
}
export function LinkIcon(props: IconProps) {
return (
<Svg {...props}>
@@ -262,3 +272,24 @@ export function LinkIcon(props: IconProps) {
</Svg>
);
}
export function UserPlusIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="22" y1="11" x2="16" y2="11" />
</Svg>
);
}
export function ExternalLinkIcon(props: IconProps) {
return (
<Svg {...props}>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</Svg>
);
}
@@ -0,0 +1,79 @@
import { useTranslation } from "react-i18next";
import { Button, Card } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
import { DealStatusHero } from "@portal/components/procurement/DealStatusHero";
import type { ProcurementController } from "@portal/components/procurement/useProcurement";
/**
* The deal-status hero, wired to a shared ProcurementController. Rendered both
* standalone (the /procurement route) and as the Home hero card's footer once a
* deal is underway. Assumes an active deal (controller.data present).
*/
export function ControlledDealStatusHero({
controller,
}: {
controller: ProcurementController;
}) {
const { setActiveView } = useView();
if (!controller.data) return null;
return (
<DealStatusHero
snapshot={controller.data}
busy={controller.busy}
onExpand={() => controller.setOpen(true)}
onKeyDocs={() => controller.setExtra("docs")}
onInvite={() => setActiveView("users")}
onSchedule={() => controller.setExtra("schedule")}
onManageTrial={() => controller.setExtra("trial")}
onNavigate={setActiveView}
/>
);
}
/**
* Enterprise on-ramp shown when no deal exists yet. Only used on the dedicated
* /procurement route — on Home the setup checklist's Enterprise rung owns the
* on-ramp, so this doesn't render there.
*/
export function ProcurementUpsell({
controller,
}: {
controller: ProcurementController;
}) {
const { t } = useTranslation();
return (
<Card className="portal-proc__upsell">
<div className="portal-proc__upsell-text">
<span className="portal-proc__upsell-badge">
{t("portal.procurement.upsell.homeBadge")}
</span>
<p className="portal-proc__upsell-copy">
<strong>{t("portal.procurement.upsell.homeHeadline")} </strong>
{t("portal.procurement.upsell.homeBody")}
</p>
</div>
<Button
variant="secondary"
accent="default"
loading={controller.busy}
disabled={!controller.isLinked}
onClick={controller.onStartTrial}
>
{t("portal.procurement.upsell.homeCta")}
</Button>
</Card>
);
}
/** Deal-status hero when a deal is underway, otherwise the enterprise on-ramp. */
export function ProcurementBanner({
controller,
}: {
controller: ProcurementController;
}) {
return controller.isLinked && controller.started && controller.data ? (
<ControlledDealStatusHero controller={controller} />
) : (
<ProcurementUpsell controller={controller} />
);
}
@@ -0,0 +1,181 @@
import { useTranslation } from "react-i18next";
import { Banner, Button, EmptyState, Skeleton } from "@app/ui";
import { useUI } from "@portal/contexts/UIContext";
import { JOURNEY } from "@portal/api/procurement";
import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
import {
KeyDocumentsModal,
ScheduleCallModal,
TrialManageModal,
} from "@portal/components/procurement/ProcurementExtras";
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
import {
LiveStageCard,
PaymentStageCard,
QuoteMilestoneCard,
} from "@portal/components/procurement/ProcurementStages";
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
import { StageStepper } from "@portal/components/procurement/StageStepper";
import type { ProcurementController } from "@portal/components/procurement/useProcurement";
/**
* The procurement takeover flow: the full-screen journey modal (quote builder →
* milestone → agreement → payment → live) plus the key-documents, schedule-call,
* and trial-management modals. Driven entirely by a shared ProcurementController
* so it can sit next to a deal-status hero rendered elsewhere (e.g. inside the
* tier hero card on Home).
*/
export function ProcurementFlow({
controller,
}: {
controller: ProcurementController;
}) {
const { t } = useTranslation();
const { openLinkModal } = useUI();
const {
isLinked,
loading,
data,
started,
stage,
latest,
isIssued,
isDraft,
busy,
downloading,
error,
setError,
open,
setOpen,
editing,
setEditing,
extra,
setExtra,
invoicePdf,
onExtendTrial,
onReset,
onGenerate,
onAcceptQuote,
onAgree,
onGoLive,
onDownloadPdf,
} = controller;
return (
<>
<ProcurementModal
open={open}
onClose={() => setOpen(false)}
title={t("portal.procurement.title")}
subtitle={t("portal.procurement.subtitle")}
>
{error && (
<Banner
tone="danger"
title={t("portal.procurement.error.title")}
onDismiss={() => setError(null)}
>
{error}
</Banner>
)}
{!isLinked && (
<EmptyState
eyebrow={t("portal.procurement.link.eyebrow")}
title={t("portal.procurement.link.title")}
description={t("portal.procurement.link.description")}
actions={
<Button
variant="primary"
accent="premium"
onClick={() => openLinkModal()}
>
{t("portal.procurement.link.cta")}
</Button>
}
/>
)}
{isLinked && (loading || !started) && <Skeleton height="10rem" />}
{isLinked && started && (
<>
<div className="portal-proc__modal-stepper">
<StageStepper journey={JOURNEY} currentStage={stage!} />
</div>
{(editing ||
(isDraft && (stage === "trial" || stage === "quote"))) && (
<QuoteBuilder
deployment="cloud"
initial={latest?.config}
onGenerate={onGenerate}
/>
)}
{!editing && isIssued && stage === "quote" && latest && (
<QuoteMilestoneCard
quote={latest}
busy={busy}
downloading={downloading}
onAccept={onAcceptQuote}
onDownload={onDownloadPdf}
onEdit={() => setEditing(true)}
/>
)}
{!editing && stage === "security" && latest && (
<ProcurementAgreement
quote={latest}
busy={busy}
onAgree={onAgree}
/>
)}
{!editing && stage === "procurement" && latest && (
<PaymentStageCard
invoiceUrl={latest.invoiceUrl}
invoicePdf={invoicePdf}
busy={busy}
onSimulate={onGoLive}
/>
)}
{!editing && stage === "active" && <LiveStageCard />}
<div className="portal-proc__reset">
<button type="button" onClick={onReset} disabled={busy}>
{t("portal.procurement.reset")}
</button>
</div>
</>
)}
</ProcurementModal>
<KeyDocumentsModal
open={extra === "docs"}
onClose={() => setExtra(null)}
/>
<ScheduleCallModal
open={extra === "schedule"}
onClose={() => setExtra(null)}
/>
{data && (
<TrialManageModal
open={extra === "trial"}
onClose={() => setExtra(null)}
snapshot={data}
busy={busy}
onExtend={async () => {
await onExtendTrial();
setExtra(null);
}}
onCancel={async () => {
await onReset();
setExtra(null);
}}
/>
)}
</>
);
}
@@ -1,296 +1,22 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Banner, Button, Card, EmptyState, Skeleton } from "@app/ui";
import { useLink } from "@portal/contexts/LinkContext";
import { useUI } from "@portal/contexts/UIContext";
import { useView } from "@portal/contexts/ViewContext";
import { useAsync } from "@portal/hooks/useAsync";
import {
acceptQuote,
extendTrial,
fetchQuotePdf,
fetchSnapshot,
goLive,
issueQuote,
JOURNEY,
resetProcurement,
startAgreement,
startTrial,
type ProcurementSnapshot,
type QuoteResult,
} from "@portal/api/procurement";
import { DealStatusHero } from "@portal/components/procurement/DealStatusHero";
import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
import {
KeyDocumentsModal,
ScheduleCallModal,
TrialManageModal,
} from "@portal/components/procurement/ProcurementExtras";
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
import {
LiveStageCard,
PaymentStageCard,
QuoteMilestoneCard,
} from "@portal/components/procurement/ProcurementStages";
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
import { StageStepper } from "@portal/components/procurement/StageStepper";
import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner";
import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow";
import { useProcurement } from "@portal/components/procurement/useProcurement";
import "@portal/views/Procurement.css";
/**
* The procurement experience on Home: a compact deal-status hero once a trial is running (or the
* enterprise upsell on-ramp before it), both expanding into the full-screen takeover modal that
* holds the journey — build + issue a quote (a Stripe Quote with a real PDF, the milestone the buyer
* can share and return to), review + agree to the enterprise agreement, then accept into a committed
* subscription. Starting a trial is a single click (no "start a trial" prompt); the deadline + next
* steps then show on the hero. Rendered on Home and at /procurement (autoOpen). Gated on a link.
* The standalone procurement experience: a deal-status hero (or enterprise
* on-ramp when no deal exists) above the full-screen takeover flow that holds
* the journey — build + issue a quote, review + agree to the enterprise
* agreement, then accept into a committed subscription. Rendered at
* /procurement (autoOpen). On Home the deal-status hero instead attaches to the
* tier hero card's footer (see HomeHero) so this component isn't used there.
*/
export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) {
const { t } = useTranslation();
const { isLinked } = useLink();
const { openLinkModal } = useUI();
const { setActiveView } = useView();
const state = useAsync<ProcurementSnapshot | null>(
() => (isLinked ? fetchSnapshot() : Promise.resolve(null)),
[isLinked],
);
const [snap, setSnap] = useState<ProcurementSnapshot | null>(null);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(false);
const [downloading, setDownloading] = useState(false);
const [invoicePdf, setInvoicePdf] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [extra, setExtra] = useState<null | "docs" | "schedule" | "trial">(
null,
);
const data = snap ?? (state.loading ? null : state.data);
const started = data?.dealId != null;
const stage = data?.stage;
const latest = data?.latestQuote ?? null;
const isIssued = latest?.status === "sent" || latest?.status === "open";
// No live quote to act on (none yet, still a draft, or expired/canceled) → the buyer (re)builds.
const isDraft =
!latest ||
["draft", "expired", "canceled", "cancelled"].includes(latest.status);
async function run(fn: () => Promise<unknown>) {
setBusy(true);
setError(null);
try {
await fn();
setSnap(await fetchSnapshot());
} catch (e) {
console.error("[procurement] action failed", e);
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
const onStartTrial = () => run(startTrial);
const onExtendTrial = () => run(extendTrial);
const onReset = () =>
run(async () => {
await resetProcurement();
setEditing(false);
setInvoicePdf(null);
});
const onGenerate = (draft: QuoteResult) =>
run(async () => {
await issueQuote(draft.quoteId);
setEditing(false);
});
// Milestone → agreement (security) stage; then agreeing accepts into a subscription.
const onAcceptQuote = () => run(startAgreement);
const onAgree = () =>
run(async () => {
if (!latest) return;
const res = await acceptQuote(latest.quoteId);
setInvoicePdf(res.invoicePdf);
});
async function onDownloadPdf() {
if (!latest) return;
setDownloading(true);
try {
const blob = await fetchQuotePdf(latest.quoteId);
// A same-gesture <a download> click is reliable; window.open after an await is often
// popup-blocked (which is what made this take "a few goes").
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${latest.quoteNumber || "quote"}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (e) {
console.error("[procurement] quote PDF download failed", e);
setError(t("portal.procurement.milestone.downloadError"));
} finally {
setDownloading(false);
}
}
// A deep link (/procurement) opens the flow when a deal is already underway; if there's no deal
// yet it must NOT silently start a trial — leave the modal closed so the Start-trial CTA shows.
useEffect(() => {
if (autoOpen && started) setOpen(true);
}, [autoOpen, started]);
const banner =
isLinked && started && data ? (
<DealStatusHero
snapshot={data}
busy={busy}
onExpand={() => setOpen(true)}
onKeyDocs={() => setExtra("docs")}
onInvite={() => setActiveView("users")}
onSchedule={() => setExtra("schedule")}
onManageTrial={() => setExtra("trial")}
onNavigate={setActiveView}
/>
) : (
<Card className="portal-proc__upsell">
<div className="portal-proc__upsell-text">
<span className="portal-proc__upsell-badge">
{t("portal.procurement.upsell.homeBadge")}
</span>
<p className="portal-proc__upsell-copy">
<strong>{t("portal.procurement.upsell.homeHeadline")} </strong>
{t("portal.procurement.upsell.homeBody")}
</p>
</div>
<Button
variant="secondary"
accent="default"
loading={busy}
disabled={!isLinked}
onClick={onStartTrial}
>
{t("portal.procurement.upsell.homeCta")}
</Button>
</Card>
);
const controller = useProcurement(autoOpen);
return (
<>
{banner}
<ProcurementModal
open={open}
onClose={() => setOpen(false)}
title={t("portal.procurement.title")}
subtitle={t("portal.procurement.subtitle")}
>
{error && (
<Banner
tone="danger"
title={t("portal.procurement.error.title")}
onDismiss={() => setError(null)}
>
{error}
</Banner>
)}
{!isLinked && (
<EmptyState
eyebrow={t("portal.procurement.link.eyebrow")}
title={t("portal.procurement.link.title")}
description={t("portal.procurement.link.description")}
actions={
<Button
variant="primary"
accent="premium"
onClick={() => openLinkModal()}
>
{t("portal.procurement.link.cta")}
</Button>
}
/>
)}
{isLinked && (state.loading || !started) && <Skeleton height="10rem" />}
{isLinked && started && (
<>
<div className="portal-proc__modal-stepper">
<StageStepper journey={JOURNEY} currentStage={stage!} />
</div>
{(editing ||
(isDraft && (stage === "trial" || stage === "quote"))) && (
<QuoteBuilder
deployment="cloud"
initial={latest?.config}
onGenerate={onGenerate}
/>
)}
{!editing && isIssued && stage === "quote" && latest && (
<QuoteMilestoneCard
quote={latest}
busy={busy}
downloading={downloading}
onAccept={onAcceptQuote}
onDownload={onDownloadPdf}
onEdit={() => setEditing(true)}
/>
)}
{!editing && stage === "security" && latest && (
<ProcurementAgreement
quote={latest}
busy={busy}
onAgree={onAgree}
/>
)}
{!editing && stage === "procurement" && latest && (
<PaymentStageCard
invoiceUrl={latest.invoiceUrl}
invoicePdf={invoicePdf}
busy={busy}
onSimulate={() => run(goLive)}
/>
)}
{!editing && stage === "active" && <LiveStageCard />}
<div className="portal-proc__reset">
<button type="button" onClick={onReset} disabled={busy}>
{t("portal.procurement.reset")}
</button>
</div>
</>
)}
</ProcurementModal>
<KeyDocumentsModal
open={extra === "docs"}
onClose={() => setExtra(null)}
/>
<ScheduleCallModal
open={extra === "schedule"}
onClose={() => setExtra(null)}
/>
{data && (
<TrialManageModal
open={extra === "trial"}
onClose={() => setExtra(null)}
snapshot={data}
busy={busy}
onExtend={async () => {
await onExtendTrial();
setExtra(null);
}}
onCancel={async () => {
await onReset();
setExtra(null);
}}
/>
)}
<ProcurementBanner controller={controller} />
<ProcurementFlow controller={controller} />
</>
);
}
@@ -0,0 +1,179 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { usePortalLinked } from "@portal/contexts/usePortalLinked";
import { useAsync } from "@portal/hooks/useAsync";
import {
acceptQuote,
extendTrial,
fetchQuotePdf,
fetchSnapshot,
goLive,
issueQuote,
resetProcurement,
startAgreement,
startTrial,
type ProcurementSnapshot,
type QuoteResult,
} from "@portal/api/procurement";
export type ProcurementExtra = null | "docs" | "schedule" | "trial";
/**
* Owns the procurement deal state and actions shared by the Home hero footer
* (deal-status hero) and the takeover flow modals. Extracted from
* ProcurementHome so the deal-status hero can render inside the tier hero card
* while the flow modals live alongside it. Gated on an account link.
*/
export interface ProcurementController {
isLinked: boolean;
loading: boolean;
data: ProcurementSnapshot | null;
started: boolean;
stage: ProcurementSnapshot["stage"] | undefined;
latest: QuoteResult | null;
isIssued: boolean;
isDraft: boolean;
busy: boolean;
downloading: boolean;
error: string | null;
setError: (e: string | null) => void;
open: boolean;
setOpen: (b: boolean) => void;
editing: boolean;
setEditing: (b: boolean) => void;
extra: ProcurementExtra;
setExtra: (e: ProcurementExtra) => void;
invoicePdf: string | null;
onStartTrial: () => void;
onExtendTrial: () => void;
onReset: () => void;
onGenerate: (draft: QuoteResult) => void;
onAcceptQuote: () => void;
onAgree: () => void;
onGoLive: () => void;
onDownloadPdf: () => Promise<void>;
}
export function useProcurement(autoOpen = false): ProcurementController {
const { t } = useTranslation();
const isLinked = usePortalLinked();
const state = useAsync<ProcurementSnapshot | null>(
() => (isLinked ? fetchSnapshot() : Promise.resolve(null)),
[isLinked],
);
const [snap, setSnap] = useState<ProcurementSnapshot | null>(null);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(false);
const [downloading, setDownloading] = useState(false);
const [invoicePdf, setInvoicePdf] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [extra, setExtra] = useState<ProcurementExtra>(null);
const data = snap ?? (state.loading ? null : state.data);
const started = data?.dealId != null;
const stage = data?.stage;
const latest = data?.latestQuote ?? null;
const isIssued = latest?.status === "sent" || latest?.status === "open";
// No live quote to act on (none yet, still a draft, or expired/canceled) → the buyer (re)builds.
const isDraft =
!latest ||
["draft", "expired", "canceled", "cancelled"].includes(latest.status);
async function run(fn: () => Promise<unknown>) {
setBusy(true);
setError(null);
try {
await fn();
setSnap(await fetchSnapshot());
} catch (e) {
console.error("[procurement] action failed", e);
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
const onStartTrial = () => run(startTrial);
const onExtendTrial = () => run(extendTrial);
const onReset = () =>
run(async () => {
await resetProcurement();
setEditing(false);
setInvoicePdf(null);
});
const onGenerate = (draft: QuoteResult) =>
run(async () => {
await issueQuote(draft.quoteId);
setEditing(false);
});
// Milestone → agreement (security) stage; then agreeing accepts into a subscription.
const onAcceptQuote = () => run(startAgreement);
const onAgree = () =>
run(async () => {
if (!latest) return;
const res = await acceptQuote(latest.quoteId);
setInvoicePdf(res.invoicePdf);
});
const onGoLive = () => run(goLive);
async function onDownloadPdf() {
if (!latest) return;
setDownloading(true);
try {
const blob = await fetchQuotePdf(latest.quoteId);
// A same-gesture <a download> click is reliable; window.open after an await is often
// popup-blocked (which is what made this take "a few goes").
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${latest.quoteNumber || "quote"}.pdf`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (e) {
console.error("[procurement] quote PDF download failed", e);
setError(t("portal.procurement.milestone.downloadError"));
} finally {
setDownloading(false);
}
}
// A deep link (/procurement) opens the flow when a deal is already underway; if there's no deal
// yet it must NOT silently start a trial — leave the modal closed so the Start-trial CTA shows.
useEffect(() => {
if (autoOpen && started) setOpen(true);
}, [autoOpen, started]);
return {
isLinked,
loading: state.loading,
data,
started,
stage,
latest,
isIssued,
isDraft,
busy,
downloading,
error,
setError,
open,
setOpen,
editing,
setEditing,
extra,
setExtra,
invoicePdf,
onStartTrial,
onExtendTrial,
onReset,
onGenerate,
onAcceptQuote,
onAgree,
onGoLive,
onDownloadPdf,
};
}
@@ -0,0 +1,26 @@
import type { ComponentType, ReactNode } from "react";
import { LinkIcon } from "@portal/components/icons";
import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel";
export interface AccountLinkSettingsSeam {
/** Section key in the Settings nav + body switch. */
navKey: string;
/** i18n key for the nav label; resolved with `t()` at the call site. */
labelKey: string;
icon: ReactNode;
/** The section body — the account-link panel. */
Body: ComponentType;
}
/**
* The admin "Account link" section of Settings (self-hosted only). The SaaS
* build shadows this file with `null`: the signed-in account IS the SaaS
* account, so there is no instance to link — the nav item and its panel both
* drop out, and nothing imports the link-only AccountLinkPanel.
*/
export const accountLinkSettings: AccountLinkSettingsSeam | null = {
navKey: "account-link",
labelKey: "portal.settings.sections.account-link",
icon: <LinkIcon size={16} />,
Body: AccountLinkPanel,
};
@@ -7,7 +7,7 @@ import {
type ReactNode,
} from "react";
import { readMocksPreference } from "@portal/mocks/preference";
import { useLink, type LinkState } from "@portal/contexts/LinkContext";
import { usePlanTier } from "@portal/contexts/usePlanTier";
export type Tier = "free" | "pro" | "enterprise";
@@ -34,17 +34,6 @@ interface TierContextValue {
const TierContext = createContext<TierContextValue | null>(null);
/** Maps the real link/subscription state onto the tier the rest of the portal reads. */
function tierFromLinkState(linkState: LinkState): Tier {
switch (linkState) {
case "linked-subscribed":
return "pro";
case "linked-free":
case "unlinked":
return "free";
}
}
export function TierProvider({
children,
initialTier = "pro",
@@ -55,28 +44,30 @@ export function TierProvider({
// Mocks toggling reloads the page (see MocksToggle), so a single read at mount
// is correct — the preference can't change without us remounting.
const mocksOn = useMemo(() => readMocksPreference(), []);
const { linkState } = useLink();
// Real derived tier. Its source is a per-flavor seam: self-hosted derives it
// from the link/subscription state, SaaS from the wallet (see usePlanTier).
const derivedTier = usePlanTier();
const [mockTier, setMockTier] = useState<Tier>(initialTier);
// When mocks are off, mirror the real link state into the tier so any
// component still keyed on `tier` (sidebar plan badge, gated panels) stays
// consistent with the wallet. When mocks are on, the dropdown wins.
// When mocks are off, mirror the derived tier so any component keyed on `tier`
// (sidebar plan badge, gated panels) stays consistent. When mocks are on, the
// dropdown wins.
useEffect(() => {
if (!mocksOn) {
setMockTier(tierFromLinkState(linkState));
setMockTier(derivedTier);
}
}, [mocksOn, linkState]);
}, [mocksOn, derivedTier]);
const value = useMemo<TierContextValue>(
() => ({
tier: mocksOn ? mockTier : tierFromLinkState(linkState),
tier: mocksOn ? mockTier : derivedTier,
// Setter is a no-op when mocks are off — UI controls can disable themselves
// via `isDerived`, but even if one slips through, it has no effect.
setTier: mocksOn ? setMockTier : () => {},
isDerived: !mocksOn,
}),
[mocksOn, mockTier, linkState],
[mocksOn, mockTier, derivedTier],
);
return <TierContext.Provider value={value}>{children}</TierContext.Provider>;
@@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
export type ViewId =
| "home"
@@ -55,9 +56,10 @@ export const VIEW_PATHS: Record<ViewId, string> = {
* The portal is mounted as a route-set under this base path inside the editor
* app (see the admin-route seam). VIEW_PATHS stay expressed as logical portal
* paths; this facade adds/strips the base so components keep navigating by
* ViewId without knowing where the portal is mounted.
* ViewId without knowing where the portal is mounted. The constant lives in
* core so portal-free build flavors can reference the mount point too.
*/
export const PORTAL_BASENAME = "/portal";
export { PORTAL_BASENAME };
/** Logical view path -> full app path (e.g. "/users" -> "/portal/users"). */
export function toPortalPath(viewPath: string): string {

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