diff --git a/.dockerignore b/.dockerignore
index 33be4b6ee1..492480e3a0 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -27,7 +27,6 @@ node_modules/
**/node_modules/
frontend/node_modules/
frontend/editor/dist/
-frontend/dist-portal/
frontend/editor/playwright-report/
.npm/
.yarn/
diff --git a/.gitleaksignore b/.gitleaksignore
index 783a098f68..089de4fedb 100644
--- a/.gitleaksignore
+++ b/.gitleaksignore
@@ -15,10 +15,10 @@ testing/compose/validate-mcp-test.sh:curl-auth-header:92
testing/compose/validate-mcp-test.sh:curl-auth-header:116
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
-frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
+frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
-frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
+frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml
index 3ad0486759..52277b5b04 100644
--- a/.taskfiles/frontend.yml
+++ b/.taskfiles/frontend.yml
@@ -128,37 +128,6 @@ tasks:
- task: dev:_run
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
- dev:portal:
- desc: "Start developer portal dev server"
- ignore_error: true
- deps: [install]
- vars:
- PORT: '{{.PORT | default "5173"}}'
- BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
- EDITOR_URL: '{{.EDITOR_URL | default ""}}'
- OPEN: '{{.OPEN | default ""}}'
- SUBPATH: '{{.SUBPATH | default ""}}'
- MOCKS: '{{.MOCKS | default ""}}'
- env:
- BACKEND_URL: '{{.BACKEND_URL}}'
- cmds:
- - '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
-
- dev:portal:proxy:serve:
- internal: true
- vars:
- PORT: '{{.PORT | default "3000"}}'
- BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
- EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
- PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
- env:
- PORT: '{{.PORT}}'
- BACKEND_URL: '{{.BACKEND_URL}}'
- EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
- PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
- cmds:
- - npx tsx scripts/dev-origin-proxy.ts
-
# ============================================================
# Build
# ============================================================
@@ -205,29 +174,6 @@ tasks:
cmds:
- npx vite build editor --mode prototypes
- build:portal:
- desc: "Build developer portal"
- deps: [install]
- vars:
- SUBPATH: '{{.SUBPATH | default ""}}'
- cmds:
- - '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
-
- preview:portal:proxy:
- desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
- deps: [prepare]
- vars:
- PORT: '{{.PORT | default "3000"}}'
- BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
- env:
- PORT: '{{.PORT}}'
- BACKEND_URL: '{{.BACKEND_URL}}'
- cmds:
- - task: build:proprietary
- vars: { PREVIEW: '1' }
- - task: build:portal
- vars: { SUBPATH: portal }
- - npx tsx scripts/dev-origin-proxy.ts
storybook:
desc: "Start Storybook dev server"
@@ -263,8 +209,8 @@ tasks:
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
- # shell-agnostic. Covers editor, portal, and the shared design system.
- - npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
+ # shell-agnostic. Covers the whole editor tree, including the portal layer.
+ - npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
lint:fix:
desc: "Auto-fix lint issues"
@@ -345,8 +291,6 @@ tasks:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- - task: typecheck:_run
- vars: { PROJECT: scripts/tsconfig.json }
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
@@ -362,14 +306,7 @@ tasks:
deps: [install]
cmds:
- task: typecheck:_run
- vars: { PROJECT: portal/tsconfig.json }
-
- typecheck:shared:
- desc: "Typecheck the shared design system"
- deps: [install]
- cmds:
- - task: typecheck:_run
- vars: { PROJECT: shared/tsconfig.json }
+ vars: { PROJECT: editor/src/portal/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
@@ -382,7 +319,6 @@ tasks:
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
- - task: typecheck:shared
# ============================================================
# Quality Gate
@@ -411,7 +347,6 @@ tasks:
- task: lint
- task: format:check
- task: build
- - task: build:portal
- task: test
- task: storybook:build
@@ -423,7 +358,6 @@ tasks:
desc: "Run tests"
cmds:
- task: test:editor
- - task: test:portal
test:editor:
desc: "Run editor tests"
@@ -431,12 +365,6 @@ tasks:
cmds:
- npx vitest run --root editor
- test:portal:
- desc: "Run portal tests"
- deps: [prepare]
- cmds:
- - npx vitest run --root portal
-
test:watch:
desc: "Run tests in watch mode"
deps: [prepare]
@@ -481,7 +409,7 @@ tasks:
clean:
desc: "Clean build artifacts and caches"
cmds:
- - cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
+ - cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
platforms: [windows]
- - cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
+ - cmd: rm -rf node_modules/.vite editor/dist dist
platforms: [linux, darwin]
diff --git a/AGENTS.md b/AGENTS.md
index 44647b0063..9afdad5937 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -139,7 +139,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Environment Variables
- All `VITE_*` variables must be declared in the appropriate committed env file:
- - `frontend/editor/.env` — core, proprietary, and shared vars
+ - `frontend/editor/.env` — core and shared vars (base, loaded in every mode)
+ - `frontend/editor/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
- `frontend/editor/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
- `frontend/editor/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
- These files are committed to Git and must not contain private keys
diff --git a/LICENSE b/LICENSE
index 2b2f7fc085..971c9d0b16 100644
--- a/LICENSE
+++ b/LICENSE
@@ -20,8 +20,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
-* All content that resides under the "frontend/portal/" directory of this repository,
-if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
+* All content that resides under the "frontend/editor/src/portal/" directory of this repository,
+if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
diff --git a/Taskfile.yml b/Taskfile.yml
index 34c7c8bc28..26895723d0 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -79,86 +79,12 @@ tasks:
OPEN: "true"
dev:portal:
- desc: "Start backend + developer portal concurrently on free ports"
+ desc: "Start backend + editor; the portal is an admin route at /portal"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
- PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
- deps:
- - task: backend:dev
- vars:
- PORT: '{{.BACKEND_PORT}}'
- SECURITY_ENABLELOGIN: "true"
- POLICIES_ENABLED: "true"
- - task: frontend:dev:portal
- vars:
- PORT: '{{.PORTAL_PORT}}'
- BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
- MOCKS: 'false'
- OPEN: "true"
-
- dev:portal:all:
- desc: "Start backend + developer portal + editor concurrently on free ports"
- vars:
- PORTS:
- sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
- BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
- PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
- EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
- deps:
- - task: backend:dev
- vars:
- PORT: '{{.BACKEND_PORT}}'
- SECURITY_ENABLELOGIN: "true"
- POLICIES_ENABLED: "true"
- - task: frontend:dev:portal
- vars:
- PORT: '{{.PORTAL_PORT}}'
- BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
- # Point the portal's "Editor" app switcher at the editor we spawn here.
- EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
- MOCKS: 'false'
- OPEN: "true"
- - task: frontend:dev
- vars:
- PORT: '{{.EDITOR_PORT}}'
- BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
-
- dev:portal:all:saas:
- desc: "Start SaaS backend + developer portal + editor concurrently on free ports"
- vars:
- PORTS:
- sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
- BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
- PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
- EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
- deps:
- - task: backend:dev:saas
- vars:
- PORT: '{{.BACKEND_PORT}}'
- POLICIES_ENABLED: "true"
- - task: frontend:dev:portal
- vars:
- PORT: '{{.PORTAL_PORT}}'
- BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
- EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
- MOCKS: 'false'
- OPEN: "true"
- - task: frontend:dev
- vars:
- PORT: '{{.EDITOR_PORT}}'
- BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
-
- dev:portal:proxy:
- desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
- vars:
- PORTS:
- sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
- BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
- PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
- EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
- PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
+ EDITOR_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
@@ -169,18 +95,7 @@ tasks:
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
- - task: frontend:dev:portal
- vars:
- PORT: '{{.PORTAL_PORT}}'
- BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
- SUBPATH: portal
- MOCKS: 'false'
- - task: frontend:dev:portal:proxy:serve
- vars:
- PORT: '{{.PROXY_PORT}}'
- BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
- EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
- PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
+ OPEN: "true"
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
@@ -228,24 +143,6 @@ tasks:
- task: backend:build
- task: frontend:build
- preview:portal:proxy:
- desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
- vars:
- PORTS:
- sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
- BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
- PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
- deps:
- - task: backend:dev
- vars:
- PORT: '{{.BACKEND_PORT}}'
- SECURITY_ENABLELOGIN: "true"
- POLICIES_ENABLED: "true"
- - task: frontend:preview:portal:proxy
- vars:
- PORT: '{{.PROXY_PORT}}'
- BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
-
# ============================================================
# Test
# ============================================================
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java
index 9d0108db0f..4b32d6ae6a 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java
@@ -2,7 +2,8 @@ package stirling.software.proprietary.access.model;
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
public enum ResourceType {
- // The admin portal / processor (frontend/portal). Singleton resource (empty resourceId).
+ // The admin portal / processor (frontend/editor/src/portal). Singleton resource (empty
+ // resourceId).
PORTAL,
// A stored S3/MCP/API integration configuration.
INTEGRATION_CONFIG
diff --git a/frontend/.gitignore b/frontend/.gitignore
index 3a5de11c57..e07dce196a 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -11,7 +11,6 @@
# production
/build
/dist
-/dist-portal
/storybook-static
/editor/build
@@ -27,8 +26,8 @@
# Root .gitignore ignores all .env* - whitelist only our committed ones, anchored
# to their app so a stray top-level frontend/.env stays ignored (Storybook's SaaS
# mock env is injected via .storybook/main.ts, not a file).
-!/portal/.env
!/editor/.env
+!/editor/.env.proprietary
!/editor/.env.desktop
!/editor/.env.saas
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
index 63d7130f47..25caf7cd10 100644
--- a/frontend/.prettierignore
+++ b/frontend/.prettierignore
@@ -1,5 +1,4 @@
dist/
-dist-portal/
editor/dist/
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier).
# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each
@@ -9,7 +8,7 @@ editor/src-tauri/gen/
node_modules/
editor/public/vendor/
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
-portal/public/mockServiceWorker.js
+editor/public/mockServiceWorker.js
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
editor/public/og-metadata.json
editor/src/core/data/ogImageMap.json
diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
index c016a5c37d..4d4aadc8f8 100644
--- a/frontend/.storybook/main.ts
+++ b/frontend/.storybook/main.ts
@@ -6,16 +6,13 @@ import tsconfigPaths from "vite-tsconfig-paths";
* Storybook 9 ships essentials, interactions, and docs as built-ins, so the
* addon list is just the extras we want: theme switching + a11y auditing.
*
- * Story files live next to their components in shared/, portal/src/, and
- * editor/src/ — the design system is shared by BOTH apps, so both surface
- * their stories here. MDX docs pages live in portal/src/docs/.
+ * Story files live next to their components under editor/src/ (which includes
+ * the portal layer at editor/src/portal/). MDX docs pages live in
+ * editor/src/portal/docs/.
*/
const config: StorybookConfig = {
stories: [
- "../portal/src/**/*.mdx",
- "../portal/src/**/*.stories.@(ts|tsx)",
- "../shared/**/*.mdx",
- "../shared/**/*.stories.@(ts|tsx)",
+ "../editor/src/portal/**/*.mdx",
"../editor/src/**/*.stories.@(ts|tsx)",
],
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
@@ -26,17 +23,21 @@ const config: StorybookConfig = {
typescript: {
reactDocgen: "react-docgen-typescript",
},
- // Serve the MSW worker file from portal/public so Storybook can intercept
- // network calls the same way the dev portal does.
- staticDirs: ["../portal/public"],
+ // Serve the MSW worker file from the portal's public dir so Storybook can
+ // intercept network calls the same way the dev portal does.
+ staticDirs: ["../editor/public"],
viteFinal: async (config) => {
- // Wire @portal/* and @shared/* aliases directly on the Storybook bundler so
- // portal story imports resolve without needing the portal's vite config.
+ // Wire the @portal/* alias directly on the Storybook bundler so portal
+ // story imports resolve without needing the portal's vite config.
config.resolve = config.resolve ?? {};
config.resolve.alias = {
...(config.resolve.alias ?? {}),
- "@portal": resolve(__dirname, "../portal/src"),
- "@shared": resolve(__dirname, "../shared"),
+ "@portal": resolve(__dirname, "../editor/src/portal"),
+ // Direct layer aliases so .storybook config files (preview.tsx), which sit
+ // outside src/ and so aren't covered by tsconfigPaths, can import layer
+ // modules (e.g. the auth supabase client that moved into proprietary).
+ "@proprietary": resolve(__dirname, "../editor/src/proprietary"),
+ "@core": resolve(__dirname, "../editor/src/core"),
};
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
// @proprietary/*. Resolve them exactly the way the editor's own build does —
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
index 1f9c935fe6..d086613f44 100644
--- a/frontend/.storybook/preview.tsx
+++ b/frontend/.storybook/preview.tsx
@@ -19,11 +19,11 @@ import { ThemeProvider } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme";
import { handlers } from "@portal/mocks/handlers";
-import { configureSupabase } from "@shared/auth/supabase/supabaseClient";
+import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
import "@mantine/core/styles.css";
-import "@shared/tokens/tokens.css";
-import "@shared/tokens/base.css";
+import "@core/tokens/tokens.css";
+import "@core/tokens/base.css";
// Start MSW once. Storybook runs in a browser so this uses the service worker.
initialize({ onUnhandledRequest: "bypass" }, handlers);
diff --git a/frontend/editor/.env.proprietary b/frontend/editor/.env.proprietary
new file mode 100644
index 0000000000..ed5498ba3a
--- /dev/null
+++ b/frontend/editor/.env.proprietary
@@ -0,0 +1,32 @@
+###############################################################################
+# Proprietary-build environment variables, layered on top of `.env` when Vite
+# runs in `--mode proprietary` (so the core/OSS build never sees them). These
+# back the admin portal mounted at /portal. Committed to Git, so no private keys;
+# machine-specific overrides go in the uncommitted .env.proprietary.local /
+# .env.local. (VITE_STRIPE_PUBLISHABLE_KEY lives in the base `.env` as it is
+# shared across builds.)
+###############################################################################
+
+# Where the portal's "Editor" switcher / non-admin redirect points. "/" is
+# correct: the editor serves the portal at /portal on the same origin. In dev,
+# override in .env.local with your running editor's URL (e.g.
+# VITE_EDITOR_URL=http://localhost:5173/).
+VITE_EDITOR_URL=/
+
+# Force the portal's MSW mocks on ("true") or off ("false"). Empty = default
+# (on in dev, off in production builds). Set "false" to run against the real
+# 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
+# the editor's vite proxy). Absent -> wallet/billing surfaces stay on the mock.
+VITE_SAAS_API_URL=
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 7f81e56f3b..d07d8379da 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -6048,6 +6048,1857 @@ stepOf = "Step {{step}} of {{total}}"
toolChainDesc = "Configure the tools this policy runs on each document."
typesSelected = "{{count}} types selected"
+[portal.accountLink.card]
+billingNote = "Unattended processing bills against your org wallet."
+eyebrow = "Account link"
+linkButton = "Link your Stirling account"
+linked = "Linked"
+linkedAs = "Linked as {{name}}."
+linkedGeneric = "This instance is linked."
+notLinked = "Not linked"
+title = "Link this org to its Stirling account"
+unlink = "Unlink"
+
+[portal.accountLink.card.error]
+title = "Couldn't link"
+
+[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."
+before = "Set"
+title = "SaaS login not configured"
+
+[portal.accountLink.gate]
+action = "Link account"
+description = "Link this org's Stirling account to use billable features."
+title = "Link to unlock"
+titleFeature = "Link to unlock {{feature}}"
+
+[portal.accountLink.instances]
+active = "Active"
+revoke = "Revoke"
+revoked = "Revoked"
+unnamed = "Unnamed instance"
+
+[portal.accountLink.instances.columns]
+instance = "Instance"
+lastSeen = "Last seen"
+linked = "Linked"
+status = "Status"
+
+[portal.accountLink.instances.empty]
+description = "Link this org's account, then register your self-hosted instances to see them here."
+title = "No linked instances"
+
+[portal.accountLink.instances.time]
+daysAgo_one = "{{count}}d ago"
+daysAgo_other = "{{count}}d ago"
+hoursAgo_one = "{{count}}h ago"
+hoursAgo_other = "{{count}}h ago"
+justNow = "just now"
+minutesAgo_one = "{{count}}m ago"
+minutesAgo_other = "{{count}}m ago"
+never = "never"
+
+[portal.accountLink.modal]
+linkSubtitle = "Sign in to the account this server should bill against."
+linkTitle = "Link your Stirling account"
+reauthSubtitle = "Your session expired — sign back in to your Stirling account. Your instance stays linked."
+reauthTitle = "Sign in again"
+simulateSignIn = "Simulate sign-in (dev)"
+
+[portal.accountLink.modal.loginNotConfigured]
+after = "to enable in-app linking against the hosted Stirling account."
+and = "and"
+before = "Set"
+title = "SaaS login not configured"
+
+[portal.accountLink.panel]
+instancesSub = "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access."
+instancesTitle = "Linked instances"
+revokeError = "Couldn't revoke instance"
+sub = "Link this self-hosted org to its Stirling account so unattended processing bills against your org wallet."
+
+[portal.accountLink.panel.loadError]
+forbidden = "Only the team owner can view the org's linked instances."
+generic = "Couldn't load the team's linked instances. Try again in a moment."
+title = "Couldn't load linked instances"
+
+[portal.accountLink.state]
+free = "Editor plan"
+subscribed = "Processor plan"
+unlinked = "Not linked"
+
+[portal.agentBuilder]
+bootstrapFromDocument = "Bootstrap from document"
+sectionsAriaLabel = "Agent builder sections"
+selectorAriaLabel = "Agents"
+subtitle = "Design, test and ship the AI agents that classify, extract from and route your documents. Define scenarios, fence tool access, run a golden set, and publish a version."
+title = "Agent Builder"
+
+[portal.agentBuilder.bootstrap]
+cancel = "Cancel"
+dropzoneText = "Choose a sample document (PDF or image)"
+lead = "Drop a sample document and we'll propose scenarios and an extraction schema you can refine. Nothing is published until you review it."
+submit = "Bootstrap agent"
+subtitle = "Seed a new agent from one representative file"
+title = "Bootstrap from a document"
+
+[portal.agentBuilder.empty]
+description = "Bootstrap an agent from a sample document to seed its scenarios and extraction schema, then refine and publish."
+title = "No agents yet"
+
+[portal.agentBuilder.evals]
+casesPassing = "Cases passing"
+columnCase = "Eval case"
+columnLatency = "Latency"
+columnResult = "Result"
+fail = "fail"
+goldenSetPassRate = "Golden-set pass rate"
+latencyMs = "{{ms}} ms"
+notRun = "not run"
+pass = "pass"
+passRate = "Pass rate"
+runEvals = "Run evals"
+
+[portal.agentBuilder.evals.empty]
+description = "Evals turn your scenarios into a repeatable golden set. Upgrade to capture pass-rate over time and gate publishes on it."
+title = "No golden set yet"
+
+[portal.agentBuilder.kpi]
+acrossGoldenSets = "across golden sets"
+activeAgents = "Active agents"
+avgPassRate = "Avg eval pass rate"
+fleetWide = "fleet-wide"
+latestPublished = "Latest published"
+scenarios = "Scenarios"
+testCases = "test cases"
+totalDescription_one = "{{count}} total"
+totalDescription_other = "{{count}} total"
+
+[portal.agentBuilder.scenarios]
+add = "Add"
+addScenario = "Add scenario"
+enable = "Enable"
+expectationLabel = "Expected behaviour"
+expectationPlaceholder = "What the agent should do"
+inEval = "in eval"
+mute = "Mute"
+muted = "muted"
+nameLabel = "Name"
+namePlaceholder = "e.g. Compliance escalation"
+
+[portal.agentBuilder.tabs]
+evals = "Evals"
+scenarios = "Scenarios"
+tools = "Tools"
+versions = "Versions"
+
+[portal.agentBuilder.tools]
+broadAccess = "Broad access"
+deniedHint = "Selected tools are blocked. Everything else stays callable."
+deniedTools = "Denied tools"
+governanceGate = "Tool governance is available on the Enterprise plan."
+restricted = "Restricted"
+restrictedAccess = "Restricted tool access"
+restrictedDescription = "Allow every tool except the ones you deny below."
+
+[portal.agentBuilder.versions]
+current = "current"
+historyGate = "Full version history and rollback are available on the Enterprise plan."
+publish = "Publish"
+rollBack = "Roll back"
+
+[portal.assistant]
+close = "Close assistant"
+error = "Couldn't reach the assistant."
+errorWithDetail = "Couldn't reach the assistant: {{detail}}"
+inputAriaLabel = "Ask the assistant"
+inputPlaceholder = "Ask about Stirling…"
+open = "Open assistant"
+send = "Send"
+title = "Assistant"
+tryAsking = "Try asking"
+typing = "Typing"
+
+[portal.auth]
+loading = "Loading"
+redirectingToEditor = "Redirecting to the editor..."
+
+[portal.billing.checkout]
+noClientSecret = "Edge function returned no client_secret."
+subtitle = "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest."
+title = "Turn on the Processor plan"
+
+[portal.billing.checkout.error]
+title = "Couldn't start checkout"
+
+[portal.billing.checkout.notConfigured]
+bodyAfter = "in the portal env to enable in-app checkout."
+bodyBefore = "Set"
+title = "Stripe not configured"
+
+[portal.billing.enterpriseUpsell]
+cta = "Build your Enterprise quote"
+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"
+
+[portal.billing.freeEditors]
+activeThisMonth = "Active this month"
+cost = "Cost"
+editorsDeployed = "Editors deployed"
+inviteTeammates = "Invite teammates"
+pdfsEdited = "PDFs edited"
+previewBadge = "Preview · sample data"
+subtitle = "Deploy anywhere, for your whole team."
+title = "Free PDF Editors"
+
+[portal.billing.freePlan]
+checkoutErrorTitle = "Couldn't start checkout"
+currentPlan = "Current plan"
+freeForever = "Free forever"
+noTeamResolved = "No team is resolved on your wallet yet — refresh and try again."
+ownerOnly = "Only the team owner can switch on the Processor plan."
+planName = "Editor"
+ssoIncluded = "SSO included"
+switchOnProcessor = "Switch on the Processor →"
+unlimitedUsers = "Unlimited users"
+
+[portal.billing.invoices]
+columnAmount = "Amount"
+columnDate = "Date"
+columnDescription = "Description"
+columnPdfsProcessed = "PDFs processed"
+columnStatus = "Status"
+descriptionFallback = "Invoice"
+downloadAriaLabel = "Download invoice {{number}} as PDF"
+emptyDescription = "Once your team subscribes and the first cycle closes, your invoices appear here."
+emptyTitle = "No invoices yet"
+fetchLimitNote_one = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal."
+fetchLimitNote_other = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal."
+loadError = "Couldn't load invoices: {{error}}"
+pdfLink = "PDF ↓"
+showAll_one = "Show all {{count}}"
+showAll_other = "Show all {{count}}"
+showFewer_one = "Show fewer (top {{count}})"
+showFewer_other = "Show fewer (top {{count}})"
+showMostRecent_one = "Show {{count}} most recent"
+showMostRecent_other = "Show {{count}} most recent"
+title = "Invoice history"
+viewAriaLabel = "View invoice {{number}} in Stripe"
+viewLink = "View ↗"
+
+[portal.billing.linkPrompt]
+cta = "Link Stirling account"
+description = "Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use."
+title = "Link your Stirling account"
+
+[portal.billing.paymentMethod]
+billedMonthly = "Billed monthly"
+cardEnding = "{{brand}} ending {{last4}}"
+cardFallback = "Card"
+expiresBilledMonthly = "Expires {{expiry}} · billed monthly"
+eyebrow = "Payment method"
+managedSub = "Your card and billing details are kept securely in Stripe's customer portal."
+managedTitle = "Managed in Stripe"
+update = "Update"
+
+[portal.billing.pdfsProcessed]
+emptyPeriod = "No metered processing yet this period."
+eyebrow = "PDFs processed this period"
+legendValue_one = "{{formatted}} PDFs"
+legendValue_other = "{{formatted}} PDFs"
+segbarAriaLabel = "Metered PDFs split by category"
+segmentAgentsDesc = "AI agent actions"
+segmentAgentsLabel = "Agents"
+segmentApiDesc = "Direct API requests"
+segmentApiLabel = "API"
+segmentAutomationDesc = "Automations & pipelines"
+segmentAutomationLabel = "Automation"
+unit = "metered PDFs"
+
+[portal.billing.spendLimit]
+adjustLimit = "Adjust limit"
+cancel = "Cancel"
+capControlNote = "Changes apply immediately — raise or lower the ceiling any time."
+capSuffix = "/ month"
+capSuffixWithDocs = "/ month · ≈ {{documents}} documents"
+displaySub = "You're only billed for what you process automatically — never past the ceiling."
+editTitle = "Set your monthly ceiling"
+eyebrow = "Spend limit"
+guardrailBody = "a hard ceiling — you're never billed past it. At the cap, metered processing pauses (unlimited PDF editing keeps working) until you raise it or the cycle resets. Nothing is lost."
+guardrailLabel = "Your guardrail:"
+noCap = "no cap"
+pctUsed = "{{pct}}% used"
+remaining = "{{amount}} remaining"
+save = "Save limit"
+saveError = "Couldn't save limit"
+thisPeriodUncapped = "{{amount}} this period · uncapped"
+usedThisMonth = "{{amount}} used this month"
+useSuggested = "Use suggested · {{amount}} / month"
+
+[portal.billing.spendLimit.projection]
+body_one = "At {{rate}}/day you reach the cap in ~{{count}} day (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}."
+body_other = "At {{rate}}/day you reach the cap in ~{{count}} days (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}."
+label = "Projected to exceed."
+
+[portal.billing.spendThisMonth]
+eyebrow = "Spend this month"
+processed_one = "{{formattedCount}} PDF processed."
+processed_other = "{{formattedCount}} PDFs processed."
+processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each."
+processedWithRate_other = "{{formattedCount}} PDFs processed, at {{rate}} each."
+
+[portal.billing.subscribedPlan.capWarn]
+approachingBody = "Raise it now so automated processing never pauses."
+approachingTitle = "You're at {{pct}}% of your monthly spend limit"
+raiseLimit = "Raise limit"
+reachedBody = "Metered processing is paused until you raise the limit or the cycle resets. Unlimited PDF editing keeps working."
+reachedTitle = "Monthly spend limit reached"
+
+[portal.billing.subscribedPlan.portalError]
+title = "Couldn't open Stripe portal"
+
+[portal.billing.walletMeter]
+capSuffix_one = "of {{allowance}} free PDFs used"
+capSuffix_other = "of {{allowance}} free PDFs used"
+eyebrow = "Processor trial"
+statusLabel_one = "{{remaining}} left"
+statusLabel_other = "{{remaining}} left"
+sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
+title_one = "Process {{allowance}} PDFs free"
+title_other = "Process {{allowance}} PDFs free"
+titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF"
+titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF"
+
+[portal.catalogue.card]
+lockedAriaLabel = "Locked"
+openAriaLabel = "Open {{name}} component"
+
+[portal.catalogue.detail]
+addToProject = "Add to project"
+ariaLabel = "Component detail"
+tabsAriaLabel = "Component detail sections"
+upgradeToUnlock = "Upgrade to unlock"
+
+[portal.catalogue.detail.code]
+install = "Install"
+usage = "Usage"
+
+[portal.catalogue.detail.locked]
+description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it."
+title = "Not available on your plan"
+
+[portal.catalogue.detail.preview]
+badge = "Live preview"
+note = "Interactive sandbox renders here"
+
+[portal.catalogue.detail.pricing]
+note = "Metered per {{unit}}. Usage beyond the monthly free quota is billed to your account and itemised under Usage & Billing."
+
+[portal.catalogue.detail.stats]
+billedOn = "Billed on"
+embeds30d = "Embeds (30d)"
+freeQuota = "Free quota"
+freeQuotaValue = "{{amount}} / mo"
+maturity = "Maturity"
+none = "None"
+perAction = "Per action"
+price = "Price"
+
+[portal.catalogue.detail.tabs]
+code = "Code"
+overview = "Overview"
+pricing = "Pricing"
+props = "Props / API"
+
+[portal.catalogue.props]
+optional = "optional"
+required = "required"
+
+[portal.catalogue.props.columns]
+description = "Description"
+name = "Prop"
+required = "Required"
+type = "Type"
+
+[portal.catalogue.summary]
+componentsGa = "Components GA"
+componentSpendMtd = "Component spend (MTD)"
+embedsThisMonth = "Embeds this month"
+inBeta = "In beta"
+
+[portal.common]
+inviteMember = "Invite member"
+
+[portal.componentsView]
+subtitle = "Embeddable SDK widgets you drop into your own app — a viewer, an e-sign flow, an AI review panel. Each is metered per action. Click a card for install, usage and props."
+title = "Components"
+
+[portal.componentsView.empty]
+description = "The component catalogue could not be loaded. Try again shortly."
+title = "No components available"
+
+[portal.componentsView.lockedBanner]
+description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
+title = "Some components need a paid plan"
+
+[portal.docs.authentication]
+codeCaption = "every request"
+eyebrow = "GETTING STARTED"
+lead = "All requests authenticate with a bearer token. Keys are scoped per environment and never expire unless rotated."
+liveKey = "Production keys — billed, rate-limited per your plan."
+testKey = "Sandbox keys — free, return synthetic fixtures."
+title = "Authentication"
+
+[portal.docs.components]
+codeCaption = "embed the viewer"
+eyebrow = "COMPONENTS"
+lead = "Embeddable UI for review queues and document inspection. Bring your own styles or use the shipped theme."
+title = "Drop-in viewers"
+
+[portal.docs.endpoints]
+eyebrow = "API REFERENCE"
+fieldCount_one = "{{count}} field"
+fieldCount_other = "{{count}} fields"
+filterAll = "All"
+filterAriaLabel = "Filter endpoints by vertical"
+lead = "Every document type is a typed endpoint. POST a file, receive schema-validated JSON. Filter by vertical below."
+title = "Endpoints"
+
+[portal.docs.errors]
+codeCaption = "422 Unprocessable Entity"
+eyebrow = "API REFERENCE"
+lead = "Errors return a stable machine-readable code plus a human message. The 4xx body always includes a request_id for support."
+title = "Errors"
+
+[portal.docs.nav]
+ariaLabel = "Documentation"
+
+[portal.docs.nav.empty]
+description = "The documentation index could not be loaded."
+title = "Docs unavailable"
+
+[portal.docs.quickstart]
+eyebrow = "GETTING STARTED"
+lead = "Send your first document to a typed endpoint and get structured JSON back in three steps. No model training, no prompt engineering."
+title = "Quickstart"
+
+[portal.docs.quickstart.callout]
+bodyAfterLink = "for copy-paste recipes."
+bodyBeforeLink = "wire the same call into a pipeline to chain validation, redaction, and delivery — or expose it to an agent over MCP. See"
+label = "Next:"
+link = "Playbooks"
+
+[portal.docs.quickstart.step1]
+body = "Create a scoped key from the Infrastructure tab. Keys carry rate limits and an optional IP allowlist. Export it into your shell:"
+title = "Issue an API key"
+
+[portal.docs.quickstart.step2]
+body = "POST a file to any typed endpoint. The endpoint determines the schema you get back — here, the invoice extractor."
+snippetCaption = "extract an invoice"
+title = "Send a document"
+
+[portal.docs.quickstart.step3]
+body = "Every response is validated against the endpoint schema, with a confidence score and per-field provenance."
+codeCaption = "200 OK"
+title = "Read the structured result"
+
+[portal.docs.rateLimits]
+burst = "Burst"
+codeCaption = "429 Too Many Requests"
+concurrency = "Concurrency"
+eyebrow = "GETTING STARTED"
+lead = "Limits scale with your plan. A 429 response includes a Retry-After header; the SDKs back off automatically."
+requestsPerMinute = "Requests / minute"
+title = "Rate limits & quotas"
+
+[portal.docs.recipes]
+cloneButton = "Clone recipe"
+eyebrow = "PLAYBOOKS"
+lead = "End-to-end patterns that chain sources, operations, and destinations. Each maps to a pipeline you can clone."
+title = "Recipes"
+
+[portal.docs.sdks]
+eyebrow = "SDKS"
+lead = "First-party clients with typed responses, automatic retries, and streaming uploads. All track the same endpoint catalogue."
+title = "Official SDKs"
+
+[portal.docs.sdks.status]
+beta = "Beta"
+deprecated = "Deprecated"
+
+[portal.docs.skills]
+eyebrow = "SKILLS"
+lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
+title = "Agent skills"
+
+[portal.docs.webhooks]
+codeCaption = "document.processed"
+eyebrow = "API REFERENCE"
+lead = "Subscribe to document.processed, pipeline.completed, and quota.threshold events. Payloads are signed with HMAC-SHA256."
+title = "Webhooks"
+
+[portal.docs.webhooks.callout]
+afterHelper = "helper."
+beforeHelper = "header against your signing secret before trusting a payload. SDKs ship a"
+beforeSignature = "Verify the"
+
+[portal.documents]
+subtitle = "Review and approve documents moving through your pipelines."
+title = "Documents"
+
+[portal.documents.audit]
+empty = "No events recorded yet."
+
+[portal.documents.drawer]
+sectionsAriaLabel = "Document detail sections"
+
+[portal.documents.drawer.tabs]
+audit = "Audit"
+extractions = "Extractions"
+overview = "Overview"
+
+[portal.documents.elevation]
+requestAccess = "Request access"
+
+[portal.documents.elevation.active]
+description = "Temporary grant — access is logged and time-boxed."
+descriptionFourEyes = "Temporary grant — a peer reviewer was notified (four-eyes)."
+title = "Access expires in {{time}}"
+
+[portal.documents.elevation.gated]
+description = "Content is gated by zero-standing-access. Requesting starts a time-boxed grant."
+descriptionFourEyes = "Content is gated by zero-standing-access. Requesting starts a time-boxed grant and notifies a peer reviewer (four-eyes)."
+title = "Sensitive document"
+
+[portal.documents.extractions]
+empty = "No fields were extracted from this document."
+masked = "Extracted fields are hidden. Request timed access to view this document's content."
+
+[portal.documents.extractions.columns]
+confidence = "Confidence"
+field = "Field"
+value = "Value"
+
+[portal.documents.filters]
+all = "All"
+archived = "Archived"
+ariaLabel = "Filter documents by status"
+needsReview = "Needs review"
+processed = "Processed"
+
+[portal.documents.overview]
+confidence = "Confidence"
+fieldsExtracted = "Fields extracted"
+received = "Received"
+source = "Source"
+status = "Status"
+type = "Type"
+
+[portal.documents.queue.empty]
+description = "As sources feed documents into your pipelines they'll appear here for review."
+title = "No documents in the queue"
+
+[portal.documents.summary]
+avgConfidence = "Avg confidence"
+inQueue = "In queue"
+needsReview = "Needs review"
+processedToday = "Processed today"
+
+[portal.documents.table]
+empty = "No documents match this filter."
+sensitiveLabel = "Sensitive"
+sensitiveTitle = "Sensitive — access required"
+
+[portal.documents.table.columns]
+confidence = "Confidence"
+fields = "Fields"
+name = "Name"
+source = "Source"
+status = "Status"
+time = "Time"
+type = "Type"
+
+[portal.editorAdmin]
+subtitle = "Deploy the Stirling PDF Editor, pair self-hosted instances to your org, and operate the running fleet — targets, health, credentials, and offline activation in one place."
+title = "Editor deployment"
+
+[portal.editorAdmin.health.columns]
+activeUsers = "Active users"
+host = "Host"
+lastSeen = "Last seen"
+region = "Region"
+status = "Status"
+version = "Version"
+
+[portal.editorAdmin.health.empty]
+description = "Deploy a target and pair it to see live instance health here."
+title = "No instances reporting"
+
+[portal.editorAdmin.offlineActivation]
+enterpriseTag = "Enterprise"
+generateButton = "Generate offline bundle"
+lockCopy = "Offline and on-prem activation is part of Enterprise."
+subtitle = "Generate a signed activation bundle for an offline or on-prem install with no outbound network path. Transfer it to the instance and apply it during first-run setup."
+talkToSales = "Talk to sales"
+title = "Air-gapped activation"
+
+[portal.editorAdmin.offlineActivation.readyBanner]
+description = "{{file}} is signed and ready to transfer. It activates one instance and expires in 14 days."
+title = "Bundle ready"
+
+[portal.editorAdmin.pairing]
+generated = "Generated ✓"
+generateNewCode = "Generate new code"
+lockCopy = "IaC provisioning is part of Enterprise."
+rotate = "Rotate"
+talkToSales = "Talk to sales"
+
+[portal.editorAdmin.sections.health]
+sub = "Every Editor instance reporting in — version, region, status, last seen, and active users."
+title = "Instance health"
+
+[portal.editorAdmin.sections.pairing]
+sub = "Connect a self-hosted editor to this org. Generate a token, hand off a short code, or wire it through IaC."
+title = "Pairing"
+
+[portal.editorAdmin.sections.targets]
+sub = "Where the Editor runs. Copy a snippet to stand up a self-hosted instance, or use Managed Cloud with no ops."
+title = "Deployment targets"
+
+[portal.editorAdmin.serviceToken]
+currentToken = "Current token"
+lastRotated = "Last rotated"
+rotateButton = "Rotate service token"
+subtitle = "Instances authenticate to the org with this credential. Rotate it on a schedule or immediately after a suspected leak."
+title = "Service token"
+
+[portal.editorAdmin.serviceToken.rotatedBanner]
+description = "A new token was issued. Update each self-hosted instance's STIRLING_SERVICE_TOKEN within the 24h grace window or they'll drop offline."
+title = "Rotate running instances"
+
+[portal.editorAdmin.targets]
+instanceCount_one = "{{count}} instance"
+instanceCount_other = "{{count}} instances"
+talkToSales = "Talk to sales"
+upgradePlan = "Upgrade plan"
+
+[portal.editorAdmin.targets.lock]
+enterprise = "On-prem and Kubernetes self-hosting are part of Enterprise."
+paid = "Self-hosting with Docker and Kubernetes unlocks on a paid plan."
+
+[portal.editorAdmin.targets.state]
+available = "Available"
+locked = "Locked"
+running = "Running"
+
+[portal.errorBoundary]
+description = "This view hit an unexpected error. Try again, or pick another section from the sidebar."
+retry = "Try again"
+title = "Something went wrong on this page"
+
+[portal.forkWizard]
+subtitle = "Clone a proven workflow and tune it — every template ships the same four-stage backbone."
+title = "Fork a starter pipeline"
+
+[portal.forkWizard.action]
+cancel = "Cancel"
+deploy = "Deploy pipeline"
+pickAnother = "Pick another"
+
+[portal.forkWizard.status]
+building = "Building…"
+ready = "Ready to deploy"
+
+[portal.home.chart.empty]
+description = "Once documents are processed, your 30-day usage appears here."
+title = "No usage yet"
+
+[portal.home.kpis.enterprise]
+docs30d = "Docs / 30d"
+evalPassRate = "Eval pass rate"
+p95Latency = "P95 latency"
+slaUptime = "SLA uptime (30d)"
+
+[portal.home.kpis.free]
+agents = "Agents"
+docsProcessed = "Docs processed"
+docsProcessedDescription = "Free plan cap"
+operations = "Operations"
+pipelines = "Pipelines"
+
+[portal.home.kpis.pro]
+agentsActive = "Agents active"
+docs30d = "Docs / 30d"
+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"
+
+[portal.home.onboarding.empty]
+description = "Onboarding tasks will appear here once your workspace is set up."
+title = "No onboarding steps yet"
+
+[portal.home.productGrid]
+ariaLabel = "Process PDFs at scale"
+
+[portal.home.productGrid.agents]
+blurb = "Wire your agent via MCP, REST, or tool definitions. Deterministic operations, scenarios, evals."
+cta = "Connect an agent"
+title = "Agents"
+
+[portal.home.productGrid.pipelines]
+badge = "Hero"
+blurb = "Compose document workflows from typed operations. Upload a sample to get suggestions or start blank."
+cta = "Build a pipeline"
+title = "Pipelines"
+
+[portal.home.productGrid.sources]
+blurb = "Attach pipelines where PDFs already live — S3, agents, SharePoint, webhooks, batch, email."
+cta = "Connect a source"
+title = "Sources"
+
+[portal.home.quickActions]
+subtitle = "Top tasks for today"
+title = "Quick actions"
+
+[portal.home.quickActions.buildPipeline]
+blurb = "3-step composer over the typed op library"
+title = "Build a pipeline"
+
+[portal.home.quickActions.connectSource]
+blurb = "S3, agents, webhooks, watched folders"
+title = "Connect a source"
+
+[portal.home.quickActions.issueApiKey]
+blurb = "Scoped key with rate limits and IP allowlist"
+title = "Issue an API key"
+
+[portal.home.quickActions.tryOp]
+blurb = "Drop a sample, pick an op, see the JSON"
+title = "Try a PDF operation"
+
+[portal.home.regions]
+subtitle = "Real-time status for every deployed Stirling region."
+title = "Region health"
+
+[portal.home.regions.empty]
+description = "Once a region is deployed, its health appears here."
+title = "No regions yet"
+
+[portal.infrastructure]
+manageEditorDeployment = "Manage Editor deployment"
+sectionsAriaLabel = "Infrastructure sections"
+subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace."
+title = "Infrastructure"
+
+[portal.infrastructure.apiKeys]
+createKey = "Create key"
+heading = "API keys"
+subheading = "Scoped credentials with per-key rate limits, permissions, and IP allowlists."
+
+[portal.infrastructure.apiKeys.card]
+allowedIps = "Allowed IPs"
+anyIp = "Any IP (no allowlist)"
+created = "Created"
+lastUsed = "Last used"
+permissions = "Permissions"
+rateLimit = "Rate limit"
+rateLimitValue = "{{value}} req/min"
+usageMonth = "Usage this month"
+usageToday = "Usage today"
+
+[portal.infrastructure.apiKeys.empty]
+description = "Create a scoped key to start calling the Stirling API."
+title = "No API keys yet"
+
+[portal.infrastructure.audit]
+filterAriaLabel = "Filter audit events by category"
+heading = "Audit logs"
+latencyValue = "{{value}} ms"
+noEventsInCategory = "No events in this category."
+subheading = "Every authentication, configuration, and processing event across your workspace."
+
+[portal.infrastructure.audit.columns]
+actor = "Actor"
+event = "Event"
+latency = "Latency"
+status = "Status"
+target = "Target"
+timestamp = "Timestamp"
+
+[portal.infrastructure.audit.empty]
+description = "Workspace activity will appear here as it happens."
+title = "No audit events"
+
+[portal.infrastructure.audit.filters]
+all = "All"
+auth = "Auth"
+config = "Config"
+elevation = "Elevation"
+processing = "Processing"
+security = "Security"
+
+[portal.infrastructure.audit.metrics]
+config = "Config"
+elevation = "Elevation"
+processing = "Processing"
+totalEvents = "Total events · 24h"
+
+[portal.infrastructure.createKey]
+cancel = "Cancel"
+createKey = "Create key"
+done = "Done"
+ipAllowlistHelper = "Comma-separated CIDR ranges. Leave blank to allow any IP."
+ipAllowlistLabel = "IP allowlist"
+keyNameLabel = "Key name"
+keyNamePlaceholder = "e.g. Production · ingest"
+permissionsLabel = "Permissions"
+secretKeyCaption = "Secret key"
+secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash — there is no way to recover it later."
+subtitle = "Scope the key to the minimum it needs. You can rotate or revoke at any time."
+subtitleCreated = "Copy this secret now — it won't be shown again."
+title = "Create API key"
+titleCreated = "Key created"
+
+[portal.infrastructure.deployments]
+msValue = "{{value}} ms"
+throughputValue = "{{value}}/min"
+
+[portal.infrastructure.deployments.deployColumns]
+deployedBy = "Deployed by"
+environment = "Environment"
+product = "Product"
+status = "Status"
+version = "Version"
+when = "When"
+
+[portal.infrastructure.deployments.recent]
+heading = "Recent deployments"
+subheading = "The latest rollouts across products and environments."
+
+[portal.infrastructure.deployments.regionColumns]
+instances = "Instances"
+latency = "Latency"
+load = "Load"
+p99 = "P99"
+region = "Region"
+status = "Status"
+throughput = "Throughput"
+uptime = "Uptime"
+version = "Version"
+
+[portal.infrastructure.deployments.regions]
+heading = "Regions"
+subheading = "Live health for every deployed Stirling region — latency, load, and rollout version."
+
+[portal.infrastructure.deployments.regions.empty]
+description = "Deployed regions appear here once your workspace is provisioned."
+title = "No regions deployed"
+
+[portal.infrastructure.models]
+heading = "Models"
+msValue = "{{value}} ms"
+subheading = "The model catalogue and routing that powers document processing across your workspace."
+
+[portal.infrastructure.models.byom]
+description = "Register an on-prem or self-hosted model and pin it to a region for data-residency-bound processing."
+title = "Bring your own model"
+
+[portal.infrastructure.models.catalogue]
+heading = "Catalogue"
+sub = "Managed models available to your workspace, with live latency and cost."
+subEnterprise = "Managed, bring-your-own, and on-prem models — with per-region pinning available."
+
+[portal.infrastructure.models.catalogue.empty]
+description = "Models in your workspace's catalogue appear here."
+title = "No models available"
+
+[portal.infrastructure.models.columns]
+cost = "Cost"
+latency = "Latency"
+load = "Load"
+model = "Model"
+status = "Status"
+type = "Type"
+version = "Version"
+
+[portal.infrastructure.models.metrics]
+activeModels = "Active models"
+avgLatency = "Avg latency"
+included = "Included"
+monthlySpend = "Monthly model spend"
+
+[portal.infrastructure.models.routing]
+empty = "No routing rules configured."
+heading = "Routing rules"
+sub = "Which model handles each operation. The default applies when no narrower rule matches."
+subLocked = "Route operations to specific models — available on paid plans."
+
+[portal.infrastructure.models.routing.lockedBanner]
+description = "Upgrade to Pro to control which model handles each operation and document type."
+title = "Model routing is a paid feature"
+
+[portal.infrastructure.models.routingColumns]
+default = "Default"
+docType = "Document type"
+modelForAria = "Model for {{operation}}"
+operation = "Operation"
+routedTo = "Routed to"
+
+[portal.infrastructure.security.access.byok]
+description = "Supply a key from your own KMS. Stirling encrypts with it but can still read."
+label = "Bring your own key (BYOK)"
+
+[portal.infrastructure.security.access.hyok]
+description = "Keys never leave your KMS. Stirling holds only ciphertext."
+label = "Hold your own key (HYOK)"
+
+[portal.infrastructure.security.access.stirling]
+description = "Stirling manages encryption keys. Simplest — zero key ops on your side."
+label = "Stirling-held keys"
+
+[portal.infrastructure.security.accessPolicy]
+heading = "Document access policy"
+subheading = "Controls who can decrypt processed documents at rest."
+
+[portal.infrastructure.security.attestations]
+heading = "Compliance attestations"
+noReport = "No report available"
+subheading = "Framework-by-framework audit posture, with reports available on attested controls."
+viewReport = "View report →"
+
+[portal.infrastructure.security.compliance]
+heading = "Compliance"
+subheading = "Attestations and certifications covering the Stirling platform."
+
+[portal.infrastructure.security.empty]
+description = "Your workspace's security configuration will appear here."
+title = "Security posture unavailable"
+
+[portal.infrastructure.security.hyokBanner]
+description = "With HYOK, encryption keys never leave your KMS. Stirling stores and processes only ciphertext you can revoke at any time."
+title = "Stirling cannot decrypt your documents"
+
+[portal.infrastructure.security.ipAllowlist]
+empty = "No IP ranges configured — all IPs allowed."
+heading = "IP allowlist"
+sub = "API access is restricted to these CIDR ranges."
+subLocked = "Restrict API access to known IP ranges — available on paid plans."
+
+[portal.infrastructure.security.ipAllowlist.lockedBanner]
+description = "Upgrade to Pro to restrict API access to specific networks."
+title = "IP allowlisting is a paid feature"
+
+[portal.infrastructure.security.ipColumns]
+added = "Added"
+addedBy = "Added by"
+cidr = "CIDR"
+label = "Label"
+
+[portal.infrastructure.security.keyManagement]
+algorithm = "Algorithm"
+heading = "Encryption key management"
+keyId = "Key identifier"
+lastRotated = "Last rotated"
+rotateKey = "Rotate key"
+rotationPolicy = "Rotation policy"
+subheading = "Custody of the keys that encrypt documents at rest — who can decrypt, and how keys rotate."
+
+[portal.infrastructure.security.managedBanner]
+description = "Bring-your-own-key (BYOK) and hold-your-own-key (HYOK) custody are available on Enterprise. Upgrade to supply keys from your own KMS."
+title = "Keys are managed by Stirling on your plan"
+
+[portal.infrastructure.security.residency.apac]
+description = "ap-southeast-1"
+label = "Asia Pacific"
+
+[portal.infrastructure.security.residency.eu]
+description = "eu-west-1 · GDPR data boundary"
+label = "European Union"
+
+[portal.infrastructure.security.residency.us]
+description = "us-east-1 · us-west-2"
+label = "United States"
+
+[portal.infrastructure.security.residencyHeader]
+heading = "Data residency"
+subheading = "Where documents are stored and processed."
+
+[portal.infrastructure.storage]
+gbValue = "{{value}} GB"
+percentUsed = "{{value}} used"
+
+[portal.infrastructure.storage.empty]
+description = "Connected storage and usage appear here."
+title = "No storage configured"
+
+[portal.infrastructure.storage.lifecycle]
+active = "Active"
+activeRange = "0–{{value}}d"
+archived = "Archived"
+coldStorage = "cold storage"
+deleted = "Deleted"
+never = "never"
+purged = "purged"
+
+[portal.infrastructure.storage.providers]
+connect = "Connect"
+connected = "Connected"
+heading = "Connected providers"
+subheading = "Where processed artifacts are written."
+
+[portal.infrastructure.storage.retention]
+heading = "Retention"
+subheading = "How long artifacts are kept before lifecycle deletion."
+windowLabel = "Default retention window"
+
+[portal.infrastructure.storage.retentionOption]
+days_one = "{{count}} day"
+days_other = "{{count}} days"
+never = "Never delete"
+
+[portal.infrastructure.storage.totalUsage]
+heading = "Total usage"
+progressLabel = "Storage used"
+subheading = "Storage consumed across all connected providers."
+
+[portal.infrastructure.tabs]
+apiKeys = "API Keys"
+audit = "Audit Logs"
+deployments = "Deployments"
+models = "Models"
+security = "Security"
+storage = "Storage"
+
+[portal.mocks.label]
+off = "Mocks OFF"
+on = "Mocks ON"
+
+[portal.mocks.tooltip]
+off = "Mock data OFF — fetch calls go to the real network. Click to re-enable mocks (reloads the page)."
+on = "Mock data ON — fetch calls are intercepted by MSW. Click to switch to the real network (reloads the page)."
+
+[portal.nav]
+agent-builder = "Agent Builder"
+components = "Components"
+docs = "Developer Docs"
+documents = "Documents"
+editor = "Editor"
+home = "Home"
+infrastructure = "Infrastructure"
+pipelines = "Pipelines"
+policies = "Policies"
+procurement = "Procurement"
+settings = "Settings"
+sources = "Sources"
+usage = "Usage & Billing"
+users = "Users"
+
+[portal.notifications]
+markAllRead = "Mark all read"
+title = "Notifications"
+viewAll = "View all"
+
+[portal.notifications.ariaLabel]
+none = "Notifications, no unread"
+unread_one = "Notifications, {{count}} unread"
+unread_other = "Notifications, {{count}} unread"
+
+[portal.notifications.count]
+allRead = "all read"
+loading = "loading"
+new_one = "{{count}} new"
+new_other = "{{count}} new"
+
+[portal.notifications.empty]
+description = "No new notifications."
+title = "You're all caught up"
+
+[portal.opRunner]
+durationMs = "{{ms}} ms"
+featuredOps = "Featured ops"
+subtitle = "Drop a sample, pick an op, see what Stirling returns."
+title = "Try a PDF operation"
+
+[portal.opRunner.action]
+close = "Close"
+openBuilder = "Open the pipeline builder"
+run = "Run operation"
+runAgain = "Run again"
+running = "Running…"
+
+[portal.opRunner.drop]
+hint = "or use a sample document."
+pickAnother = "Pick another sample"
+replaceHint = "Drop again or pick another sample to replace."
+title = "Drop a PDF here"
+useSample = "Use a sample"
+
+[portal.opRunner.empty]
+description = "Once operations are published, they'll show up here."
+title = "No featured ops yet"
+
+[portal.opRunner.error]
+title = "The operation didn't complete"
+unknown = "Unknown error"
+
+[portal.opRunner.hint]
+aSample = "a sample"
+press = "Press"
+ready = "Ready"
+toInvoke = "to invoke"
+
+[portal.opRunner.hint.runOn]
+before = "Run"
+middle = "on"
+
+[portal.opRunner.running]
+title = "Running {{label}}…"
+
+[portal.opRunner.status]
+completed = "Completed"
+failed = "Failed"
+
+[portal.pipelines]
+subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources."
+title = "Pipelines"
+
+[portal.pipelines.actions]
+newPipeline = "New pipeline"
+
+[portal.pipelines.composer]
+cancel = "Cancel"
+chainEmpty = "Add operations from the palette below."
+create = "Create pipeline"
+directory = "Output folder"
+directoryHelp = "Absolute path on the server. Must be within the configured allowed folders."
+editTitle = "Edit pipeline"
+moveDown = "Move down"
+moveUp = "Move up"
+name = "Name"
+namePlaceholder = "e.g. Redaction sweep"
+noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly."
+operations_one = "Operation ({{count}})"
+operations_other = "Operations ({{count}})"
+output = "Output"
+removeStep = "Remove operation"
+save = "Save changes"
+scheduleEvery = "Run every"
+sources = "Sources"
+sourcesLoading = "Loading sources..."
+subtitle = "Pick the sources it runs over, chain the operations, then choose when it runs and where output goes."
+title = "New pipeline"
+trigger = "Trigger"
+triggerManual = "Manual only"
+
+[portal.pipelines.composer.unit]
+days = "days"
+hours = "hours"
+minutes = "minutes"
+
+[portal.pipelines.delete]
+body = "Delete \"{{name}}\"? This can't be undone."
+cancel = "Cancel"
+confirm = "Delete"
+title = "Delete pipeline?"
+
+[portal.pipelines.detail]
+closeAriaLabel = "Close detail"
+delete = "Delete pipeline"
+edit = "Edit"
+noSources = "No sources. Files are supplied directly to each run."
+noSteps = "No operations configured."
+output = "Output"
+pause = "Pause"
+resume = "Resume"
+run = "Run now"
+sources = "Sources"
+steps = "Operations"
+subtitle = "{{trigger}} · {{status}}"
+
+[portal.pipelines.empty]
+action = "Create a pipeline"
+description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes."
+title = "No pipelines yet"
+
+[portal.pipelines.kpi]
+active = "Active"
+paused = "Paused"
+total = "Pipelines"
+
+[portal.pipelines.output]
+folder = "Write to folder"
+inline = "Return files"
+
+[portal.pipelines.run]
+completed_one = "Run completed."
+completed_other = "All {{count}} runs completed."
+empty = "Nothing to run: the sources had no documents to process."
+failed = "Run failed: {{error}}"
+running = "Run started; still in progress."
+
+[portal.pipelines.status]
+active = "Active"
+paused = "Paused"
+
+[portal.pipelines.table]
+name = "Pipeline"
+sources = "Sources"
+status = "Status"
+steps = "Steps"
+
+[portal.pipelines.trigger]
+folder-watch = "Folder watch"
+manual = "Manual"
+schedule = "Scheduled"
+
+[portal.policies]
+subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
+title = "Policies"
+
+[portal.policies.card]
+comingSoon = "Coming soon"
+notSetUp = "Not set up"
+
+[portal.policies.detail]
+enforces = "Enforces"
+onEveryExport = "On every export"
+onEveryUpload = "On every upload"
+outputAsNewFile = "as a new file"
+outputAsNewVersion = "as a new version"
+recentActivity = "Recent activity"
+retry = "Retry"
+showLess = "Show less"
+showMore = "Show more"
+sources = "Sources"
+
+[portal.policies.detail.actions]
+delete = "Delete"
+editSettings = "Edit settings"
+pause = "Pause"
+resume = "Resume"
+runNow = "Run now"
+
+[portal.policies.detail.emptyActivity]
+description = "Documents will appear here once this policy runs."
+title = "No activity yet"
+
+[portal.policies.offline]
+description = "Your policies are saved and will appear once the connection is restored."
+retry = "Retry"
+title = "Backend unavailable"
+
+[portal.policies.stats]
+activeFor = "Active"
+dataProcessed = "Data processed"
+docsEnforced = "Docs enforced"
+
+[portal.policies.status]
+active = "Active"
+paused = "Paused"
+
+[portal.policies.summary.active]
+description = "Enforcing on upload/export"
+label = "Active policies"
+
+[portal.policies.summary.categories]
+description = "Available to configure"
+label = "Categories"
+
+[portal.policies.summary.docsEnforced]
+description = "Across active policies"
+label = "Docs enforced"
+
+[portal.policies.summary.paused]
+description = "Configured but not firing"
+label = "Paused"
+
+[portal.policies.wizard.actions]
+back = "Back"
+cancel = "Cancel"
+continue = "Continue"
+enablePolicy = "Enable policy"
+saveChanges = "Save changes"
+
+[portal.policies.wizard.docTypes]
+allDescription = "Set up an Ingestion (classification) policy to narrow this to specific document types."
+allTitle = "All document types"
+clear = "Clear"
+heading = "Document types"
+narrow = "Narrow"
+selected_one = "{{count}} selected"
+selected_other = "{{count}} selected"
+
+[portal.policies.wizard.errors]
+noTools = "Enable at least one tool in the workflow first."
+saveFailed = "Couldn't save the policy. Please try again."
+
+[portal.policies.wizard.output]
+heading = "Output & run"
+
+[portal.policies.wizard.output.filenameRule]
+autoNumber = "Auto-number"
+label = "Filename rule"
+placeholder = "Text to add (optional)"
+prefix = "Prefix"
+suffix = "Suffix"
+
+[portal.policies.wizard.output.outputAs]
+label = "Output as"
+newFile = "New file"
+newVersion = "New version"
+
+[portal.policies.wizard.output.retries]
+delayLabel = "Retry delay (min)"
+heading = "Retries"
+maxLabel = "Max retries"
+
+[portal.policies.wizard.output.runOn]
+export = "Export"
+helper = "When the policy fires: on upload, or before export."
+label = "Run on"
+upload = "Upload"
+
+[portal.policies.wizard.settings]
+heading = "Settings"
+
+[portal.policies.wizard.sources]
+emptyDescription = "Connect a source on the Sources page first, then attach it to a policy here."
+emptyTitle = "No sources available"
+heading = "Sources"
+loading = "Loading sources…"
+
+[portal.policies.wizard.tabs]
+ariaLabel = "Setup steps"
+settings = "Settings"
+workflow = "Workflow"
+
+[portal.policies.wizard.title]
+edit = "Edit {{category}} policy"
+setUp = "Set up {{category}} policy"
+
+[portal.policies.wizard.workflow]
+description = "The sequence of tools this policy runs on each document. Each tool is a Stirling endpoint; toggle the ones this policy should enforce."
+
+[portal.policySummary]
+activeSummary = "{{active}} / {{total}} active"
+noRule = "No rule enforced yet"
+subtitle = "Standing automations every document passes through, regardless of which pipeline handles it."
+title = "What runs on your PDFs"
+
+[portal.policySummary.action]
+comingSoon = "Coming soon"
+configure = "Configure"
+setUp = "Set up"
+
+[portal.policySummary.column]
+activeRule = "Active rule"
+policy = "Policy"
+status = "Status"
+
+[portal.policySummary.empty]
+description = "Once policies are configured, the categories appear here."
+title = "No policies yet"
+
+[portal.policySummary.state]
+active = "Active"
+off = "Off"
+soon = "Soon"
+
+[portal.processingStatus]
+managePlan = "Manage plan"
+pdfsThisMonth = "PDFs this month"
+progressLabel = "{{used}} of {{cap}} PDFs used this month"
+upgrade = "Upgrade"
+volumeSuffix = "PDFs processed · last 30 days"
+
+[portal.procurement]
+enterpriseBadge = "Enterprise"
+subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
+title = "Procurement"
+
+[portal.procurement.action]
+download = "Download"
+pay = "Pay now"
+request = "Request"
+sign = "Review & sign"
+upload = "Upload"
+
+[portal.procurement.docs]
+count_one = "{{count}} doc"
+count_other = "{{count}} docs"
+done = "Done"
+here = "You're here"
+hide = "Hide"
+optional = "Optional"
+paidAddon = "Paid add-on"
+show = "Show"
+subtitle = "Everything you need at each step of the journey, surfaced as the deal moves through it."
+supportingSubtitle = "SOC 2, security reviews, tax forms and more, ready when your security or procurement team asks. Some carry a one-time fee."
+supportingTitle = "Supporting your evaluation"
+title = "Documents"
+upcoming = "Upcoming"
+
+[portal.procurement.journey]
+daysLeft_one = "{{count}} day left"
+daysLeft_other = "{{count}} days left"
+engineerLabel = "Your solutions engineer"
+eyebrow = "Your rollout"
+live = "You're live on Stirling Enterprise"
+nextStep = "Next step: {{action}}"
+subtitle = "Your solutions engineer is on every step. One next action at a time; the full checklist is below."
+title = "From trial to live, one guided path"
+trialTitle = "Enterprise trial"
+
+[portal.procurement.locked]
+description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation."
+eyebrow = "Enterprise only"
+talkToSales = "Talk to sales"
+title = "The procurement track opens with Enterprise"
+
+[portal.procurement.modal]
+cancel = "Cancel"
+chooseFile = "Choose file"
+downloadBody = "Your download will begin shortly."
+downloadCta = "Download"
+downloadTitle = "Download"
+noFile = "No file selected"
+payBody = "Pay your committed contract by card or bank transfer through Stripe. Your workspace provisions as soon as payment clears."
+payCta = "Continue to Stripe"
+payTitle = "Confirm payment"
+requestBodyFree = "We generate this on demand. Confirm and your solutions engineer will send it across shortly."
+requestBodyPaid = "This is a paid add-on. Confirm and your solutions engineer will scope it and send the paperwork."
+requestCta = "Request"
+requestTitle = "Request this document"
+signBody = "Opens the Stirling Enterprise Agreement for e-signature: one signature covers the MSA, order form, EULA and DPA. We countersign automatically and you advance to payment."
+signCta = "Open for signature"
+signTitle = "Review and sign your agreement"
+uploadBody = "Send us your PO and we invoice against it on your terms. Drag in the PDF or pick a file below."
+uploadCta = "Upload purchase order"
+uploadTitle = "Upload your purchase order"
+
+[portal.procurement.status]
+action = "Action needed"
+available = "Available"
+complete = "Complete"
+pending = "Pending"
+request = "On request"
+
+[portal.recentActivity]
+title = "Recent activity"
+viewAll = "View all"
+
+[portal.recentActivity.empty]
+description = "Pipeline runs, deploys and agent events will appear here."
+title = "Nothing here yet"
+
+[portal.search]
+ariaLabel = "Search"
+placeholder = "Search Stirling — endpoints, pipelines, docs…"
+
+[portal.search.empty]
+noActionsDescription = "Quick actions will appear here once they're available."
+noActionsTitle = "No quick actions"
+noMatches = "No matches for \"{{query}}\""
+noMatchesDescription = "Try a different keyword or browse the catalogue."
+
+[portal.settings]
+ariaLabel = "Settings"
+cancel = "Cancel"
+enterpriseBadge = "Enterprise"
+footerNote = "Changes apply to this workspace."
+saveChanges = "Save changes"
+
+[portal.settings.appearance]
+themeSub = "Choose how the portal looks on this device."
+themeTitle = "Theme"
+
+[portal.settings.appearance.dark]
+hint = "Dim surfaces"
+label = "Dark"
+
+[portal.settings.appearance.light]
+hint = "Bright surfaces"
+label = "Light"
+
+[portal.settings.authentication]
+sessionTimeout = "Session timeout"
+sessionTimeoutHelper = "Members re-authenticate after this idle period."
+sub = "Organisation-wide authentication controls."
+title = "Sign-in policy"
+
+[portal.settings.authentication.mfa]
+description = "Require every member to complete MFA at sign-in."
+label = "Enforce two-factor (MFA)"
+
+[portal.settings.authentication.scim]
+description = "Sync members and roles from your directory."
+label = "SCIM provisioning"
+
+[portal.settings.authentication.sso]
+description = "Federate sign-in through your identity provider."
+label = "Single sign-on (SAML)"
+
+[portal.settings.authentication.timeout]
+1440 = "24 hours"
+240 = "4 hours"
+480 = "8 hours"
+60 = "1 hour"
+720 = "12 hours"
+
+[portal.settings.earlyAccess]
+sub = "Opt into features still in preview."
+title = "Preview features"
+
+[portal.settings.groups]
+account = "Account"
+admin = "Admin"
+workspace = "Workspace"
+
+[portal.settings.notifications]
+sub = "Pick which events reach your inbox."
+title = "Email notifications"
+
+[portal.settings.notifications.pipeline-failures]
+description = "A run errors out or a step times out."
+label = "Pipeline failures"
+
+[portal.settings.notifications.pipeline-success]
+description = "Every successful pipeline run finishes."
+label = "Pipeline completions"
+
+[portal.settings.notifications.product-updates]
+description = "New operations, sources, and release notes."
+label = "Product updates"
+
+[portal.settings.notifications.security-alerts]
+description = "New API keys, sign-ins, or permission changes."
+label = "Security alerts"
+
+[portal.settings.notifications.usage-alerts]
+description = "You approach a plan limit or rate cap."
+label = "Usage & quota alerts"
+
+[portal.settings.notifications.weekly-digest]
+description = "A Monday summary of volume and health."
+label = "Weekly digest"
+
+[portal.settings.profile]
+accountFallback = "Account"
+changePhoto = "Change photo"
+email = "Email"
+emailHelper = "Used for sign-in and notification delivery."
+emailPlaceholder = "you@company.com"
+fullName = "Full name"
+namePlaceholder = "Your name"
+
+[portal.settings.sections]
+account-link = "Account link"
+appearance = "Appearance"
+authentication = "Authentication"
+early-access = "Early access"
+general = "General"
+notifications = "Notifications"
+profile = "Profile"
+sessions = "Active sessions"
+
+[portal.settings.sessions]
+revoke = "Revoke"
+sub = "Devices currently signed in to this account."
+thisDevice = "This device"
+title = "Active sessions"
+
+[portal.settings.workspace]
+manageBilling = "Manage billing"
+nameLabel = "Workspace name"
+namePlaceholder = "Workspace name"
+plan = "Plan"
+regionEnterpriseSuffix = "{{region}} · Enterprise"
+regionHelper = "Where documents are processed and stored at rest."
+regionLabel = "Data residency region"
+seats = "Seats"
+seatsUsed = "{{used}} of {{total}} used"
+
+[portal.shell.header]
+accountFallback = "Account"
+accountMenu = "Account menu"
+darkMode = "Dark mode"
+lightMode = "Light mode"
+search = "Search"
+searchPlaceholder = "Search…"
+signOut = "Sign out"
+switchToDark = "Switch to dark theme"
+switchToLight = "Switch to light theme"
+
+[portal.shell.sidebar]
+appEditor = "Editor"
+appProcessor = "Processor"
+brandSuffix = "Stirling Processor"
+docsCount = "{{docs}} docs"
+docsProcessed = "Docs processed"
+linkAccount = "Link Stirling account"
+planEnterprise = "Enterprise plan"
+planProcessor = "Processor plan"
+primaryNav = "Primary navigation"
+switchApp = "Switch app"
+
+[portal.sources]
+subtitle = "Reusable input connections that feed documents into Stirling. Configure a connection once, then reference it from any number of policies. Click a row for its config and which policies use it."
+title = "Sources"
+
+[portal.sources.actions]
+agentBuilder = "Agent Builder"
+connectSource = "Connect source"
+
+[portal.sources.delete]
+body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated."
+cancel = "Cancel"
+confirm = "Delete"
+title = "Delete source?"
+
+[portal.sources.detail]
+closeAriaLabel = "Close detail"
+delete = "Delete source"
+docs24h = "Last 24h"
+docs30d = "Last 30 days"
+docsTotal = "Total seen"
+docsTrend = "Documents over the last 30 days"
+documents = "Documents"
+edit = "Edit"
+notReferenced = "Not referenced by any policy, so it's safe to delete."
+pause = "Pause"
+resume = "Resume"
+subtitle = "{{type}} · {{status}}"
+usedBy = "Used by"
+
+[portal.sources.empty]
+description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from."
+title = "No sources connected yet"
+
+[portal.sources.kpi]
+inUse = "In use"
+total = "Connections"
+unused = "Unused"
+
+[portal.sources.status]
+active = "Active"
+disabled = "Disabled"
+unused = "Unused"
+
+[portal.sources.table]
+source = "Source"
+status = "Status"
+usedBy = "Policies"
+
+[portal.sources.types.editor]
+label = "Editor"
+
+[portal.sources.types.folder]
+description = "Watch a directory on the server for new documents."
+label = "Folder"
+
+[portal.sources.types.folder.fields.directory]
+helperText = "Absolute path Stirling watches for files to process."
+label = "Directory path"
+placeholder = "/data/incoming"
+
+[portal.sources.types.folder.fields.mode]
+label = "Read mode"
+
+[portal.sources.types.folder.fields.mode.options]
+consume = "Consume: process each file once"
+snapshot = "Snapshot: re-read the folder every run"
+
+[portal.sources.types.unknown]
+label = "Source"
+
+[portal.sources.wizard]
+back = "Back"
+cancel = "Cancel"
+continue = "Continue"
+editTitle = "Edit source"
+name = "Name"
+namePlaceholder = "e.g. Claims intake"
+save = "Save changes"
+subtitle = "Step {{current}} of {{total}} · {{label}}"
+title = "Connect a source"
+type = "Type"
+
+[portal.sources.wizard.steps]
+chooseType = "Choose type"
+configure = "Configure"
+review = "Review & connect"
+
+[portal.usage]
+managePayment = "Manage Payment"
+subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console."
+title = "Usage & billing"
+
+[portal.usage.error]
+loadWallet = "Couldn't load wallet"
+openStripePortal = "Couldn't open Stripe portal"
+walletUnavailable = "Wallet unavailable: {{status}} {{statusText}}"
+
+[portal.usage.finalizing]
+body = "It can take a few seconds for your subscription to activate. This page updates automatically."
+title = "Finalizing your subscription…"
+
+[portal.usage.sessionExpired]
+action = "Sign in again"
+body = "Your Stirling account session has expired. Sign in again to view billing — your instance stays linked."
+title = "Session expired"
+
+[portal.usageChart]
+defaultLabel = "Docs processed · last 30 days"
+delta = "{{pct}}% vs prior 30d"
+docsValue = "{{value}} docs"
+srAnnounce = "{{date}}: {{value}} docs"
+
+[portal.useCases]
+title = "Popular use cases"
+viewAll = "View all pipelines"
+
+[portal.useCases.items.authenticity]
+blurb = "Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code."
+cta = "Try authenticity check"
+eyebrow = "AUTHENTICITY"
+title = "Verify signatures and detect tampering"
+
+[portal.useCases.items.autoRouting]
+blurb = "One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow."
+cta = "Build a classifier pipeline"
+eyebrow = "AUTO-ROUTING"
+title = "Auto-classify and route incoming documents"
+
+[portal.useCases.items.piiRedaction]
+blurb = "Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline."
+cta = "See redaction pipelines"
+eyebrow = "PII REDACTION"
+title = "Redact PII before it leaves your stack"
+
+[portal.useCases.items.trainingData]
+blurb = "Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable."
+cta = "Build a training-data pipeline"
+eyebrow = "TRAINING DATA"
+title = "Turn PDFs into training data"
+
+[portal.users]
+subtitle = "The people in your organization and the access they hold — roles, status and security controls."
+title = "Users"
+
+[portal.users.access]
+subtitle = "Seats, authentication and provisioning for your organization."
+title = "Access & security"
+
+[portal.users.access.auth]
+title = "Authentication"
+
+[portal.users.access.auth.requireMfa]
+description = "Members must set up a second factor to sign in."
+enforced = "Enforced org-wide on this plan."
+label = "Require MFA"
+
+[portal.users.access.auth.shortSessions]
+description = "Sign members out after inactivity (currently {{timeout}})."
+label = "Short-lived sessions"
+
+[portal.users.access.scim]
+active = "Active"
+directory = "Directory"
+lastSync = "Last sync"
+note = "Members are created, updated and deactivated automatically from your identity provider."
+off = "Off"
+title = "SCIM provisioning"
+
+[portal.users.access.seats]
+title = "Seats"
+unlimited = "Your plan includes unlimited seats."
+usedLabel = "{{used}} of {{limit}} seats used"
+
+[portal.users.access.sso]
+connected = "Connected"
+domains = "Domains"
+manage = "Manage connection"
+notConfigured = "Not configured"
+provider = "Provider"
+title = "SSO / SAML"
+
+[portal.users.access.upgrade]
+action = "Upgrade plan"
+title = "Unlock team access controls"
+
+[portal.users.empty]
+description = "Invite your team to start collaborating on documents and pipelines."
+title = "No members yet"
+
+[portal.users.invite]
+cancel = "Cancel"
+email = "Email"
+emailError = "Enter a valid email address"
+emailPlaceholder = "teammate@acme.com"
+role = "Role"
+roleHelper = "Determines what the member can do once they join."
+send = "Send invite"
+subtitle = "They'll receive an email to join your organization."
+
+[portal.users.roles]
+subtitle = "Every role exists on every plan — what each one can do is fixed across the org."
+title = "Roles"
+
+[portal.users.summary]
+members = "Members"
+pendingInvites = "Pending invites"
+seatsUsed = "Seats used"
+
+[portal.users.table]
+actionsFor = "Actions for {{name}}"
+changeRole = "Change role"
+lastActive = "Last active"
+member = "Member"
+remove = "Remove from org"
+role = "Role"
+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"
+
[printFile]
title = "Print File"
diff --git a/frontend/portal/public/mockServiceWorker.js b/frontend/editor/public/mockServiceWorker.js
similarity index 100%
rename from frontend/portal/public/mockServiceWorker.js
rename to frontend/editor/public/mockServiceWorker.js
diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx
index f26d40f743..654691c2fc 100644
--- a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx
+++ b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx
@@ -1,5 +1,5 @@
/**
- * Editor cloud adapter over the shared {@code @shared/billing} spend-cap control:
+ * Editor cloud adapter over the shared {@code @app/billing} spend-cap control:
* supplies the i18n copy (the shared control is copy-agnostic) and the editor's
* {@code scc-*} styling. The public API (controlled {@code capUsd}/{@code
* onChange}, optional {@code onSave}/{@code saveLabel}, {@code note}) is
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import {
DEFAULT_CAP_PRESETS,
SpendCapControl as SharedSpendCapControl,
-} from "@shared/billing";
+} from "@app/billing";
// eslint-disable-next-line no-restricted-imports
import "./SpendCapControl.css";
diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx
index 308fc97d32..4c8804027c 100644
--- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx
+++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx
@@ -7,7 +7,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useWallet, type Wallet } from "@app/hooks/useWallet";
-import { currencySymbol, MeterBar, meterState } from "@shared/billing";
+import { currencySymbol, MeterBar, meterState } from "@app/billing";
import "@app/components/shared/config/configSections/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.css";
diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts
index 5a1b1d21cb..07e862fb8c 100644
--- a/frontend/editor/src/cloud/hooks/useWallet.ts
+++ b/frontend/editor/src/cloud/hooks/useWallet.ts
@@ -57,10 +57,10 @@ import type {
WalletMember,
WalletCategoryBreakdown,
WalletActivityRow,
-} from "@shared/billing";
+} from "@app/billing";
// ─── Public types ───────────────────────────────────────────────────────
-// The wallet contract lives in @shared/billing (shared with the admin portal).
+// The wallet contract lives in @app/billing (shared with the admin portal).
// Re-exported so existing `@app/hooks/useWallet` importers keep their imports.
export type {
Wallet,
diff --git a/frontend/editor/src/cloud/tsconfig.json b/frontend/editor/src/cloud/tsconfig.json
index bf3eedb2e5..2b703ea335 100644
--- a/frontend/editor/src/cloud/tsconfig.json
+++ b/frontend/editor/src/cloud/tsconfig.json
@@ -7,10 +7,10 @@
"../../src/proprietary/*",
"../../src/core/*"
],
+ "@portal/*": ["../../src/portal/*"],
"@cloud/*": ["../../src/cloud/*"],
"@proprietary/*": ["../../src/proprietary/*"],
- "@core/*": ["../../src/core/*"],
- "@shared/*": ["../../../shared/*"]
+ "@core/*": ["../../src/core/*"]
}
},
"include": [
diff --git a/frontend/shared/assets/Brand.stories.tsx b/frontend/editor/src/core/assets/Brand.stories.tsx
similarity index 75%
rename from frontend/shared/assets/Brand.stories.tsx
rename to frontend/editor/src/core/assets/Brand.stories.tsx
index 87cf9f1257..886e7a023d 100644
--- a/frontend/shared/assets/Brand.stories.tsx
+++ b/frontend/editor/src/core/assets/Brand.stories.tsx
@@ -2,16 +2,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
// Visual catalogue of the shared brand assets (shared/assets/brand) — the single
// source of truth for the Stirling logos used across the apps.
-import modernMarkDark from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
-import modernMarkLight from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
-import modernBlack from "@shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg";
-import modernWhite from "@shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg";
-import modernGrey from "@shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg";
-import classicMarkDark from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg";
-import classicMarkLight from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg";
-import classicBlack from "@shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg";
-import classicWhite from "@shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg";
-import classicGrey from "@shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg";
+import modernMarkDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
+import modernMarkLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
+import modernBlack from "@app/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg";
+import modernWhite from "@app/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg";
+import modernGrey from "@app/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg";
+import classicMarkDark from "@app/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg";
+import classicMarkLight from "@app/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg";
+import classicBlack from "@app/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg";
+import classicWhite from "@app/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg";
+import classicGrey from "@app/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg";
type Asset = { label: string; src: string; onDark?: boolean };
type VariantSet = { variant: string; mark: Asset[]; wordmark: Asset[] };
diff --git a/frontend/shared/assets/brand/classic-logo/Firstpage.png b/frontend/editor/src/core/assets/brand/classic-logo/Firstpage.png
similarity index 100%
rename from frontend/shared/assets/brand/classic-logo/Firstpage.png
rename to frontend/editor/src/core/assets/brand/classic-logo/Firstpage.png
diff --git a/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg b/frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg
similarity index 100%
rename from frontend/shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg
rename to frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg
diff --git a/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg b/frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg
similarity index 100%
rename from frontend/shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg
rename to frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg
diff --git a/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg b/frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg
similarity index 100%
rename from frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg
rename to frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg
diff --git a/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg b/frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg
similarity index 100%
rename from frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg
rename to frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg
diff --git a/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg b/frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg
similarity index 100%
rename from frontend/shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg
rename to frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg
diff --git a/frontend/shared/assets/brand/classic-logo/favicon.ico b/frontend/editor/src/core/assets/brand/classic-logo/favicon.ico
similarity index 100%
rename from frontend/shared/assets/brand/classic-logo/favicon.ico
rename to frontend/editor/src/core/assets/brand/classic-logo/favicon.ico
diff --git a/frontend/shared/assets/brand/classic-logo/logo-tooltip.svg b/frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg
similarity index 100%
rename from frontend/shared/assets/brand/classic-logo/logo-tooltip.svg
rename to frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg
diff --git a/frontend/shared/assets/brand/classic-logo/logo192.png b/frontend/editor/src/core/assets/brand/classic-logo/logo192.png
similarity index 100%
rename from frontend/shared/assets/brand/classic-logo/logo192.png
rename to frontend/editor/src/core/assets/brand/classic-logo/logo192.png
diff --git a/frontend/shared/assets/brand/classic-logo/logo512.png b/frontend/editor/src/core/assets/brand/classic-logo/logo512.png
similarity index 100%
rename from frontend/shared/assets/brand/classic-logo/logo512.png
rename to frontend/editor/src/core/assets/brand/classic-logo/logo512.png
diff --git a/frontend/shared/assets/brand/modern-logo/Firstpage.png b/frontend/editor/src/core/assets/brand/modern-logo/Firstpage.png
similarity index 100%
rename from frontend/shared/assets/brand/modern-logo/Firstpage.png
rename to frontend/editor/src/core/assets/brand/modern-logo/Firstpage.png
diff --git a/frontend/shared/assets/brand/modern-logo/LoginDarkModeHeader.svg b/frontend/editor/src/core/assets/brand/modern-logo/LoginDarkModeHeader.svg
similarity index 100%
rename from frontend/shared/assets/brand/modern-logo/LoginDarkModeHeader.svg
rename to frontend/editor/src/core/assets/brand/modern-logo/LoginDarkModeHeader.svg
diff --git a/frontend/shared/assets/brand/modern-logo/LoginLightModeHeader.svg b/frontend/editor/src/core/assets/brand/modern-logo/LoginLightModeHeader.svg
similarity index 100%
rename from frontend/shared/assets/brand/modern-logo/LoginLightModeHeader.svg
rename to frontend/editor/src/core/assets/brand/modern-logo/LoginLightModeHeader.svg
diff --git a/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg
similarity index 100%
rename from frontend/shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg
rename to frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg
diff --git a/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg
similarity index 100%
rename from frontend/shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg
rename to frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg
diff --git a/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg
similarity index 100%
rename from frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg
rename to frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg
diff --git a/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg
similarity index 100%
rename from frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg
rename to frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg
diff --git a/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg
similarity index 100%
rename from frontend/shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg
rename to frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg
diff --git a/frontend/portal/public/favicon.ico b/frontend/editor/src/core/assets/brand/modern-logo/favicon.ico
similarity index 100%
rename from frontend/portal/public/favicon.ico
rename to frontend/editor/src/core/assets/brand/modern-logo/favicon.ico
diff --git a/frontend/shared/assets/brand/modern-logo/logo-tooltip.svg b/frontend/editor/src/core/assets/brand/modern-logo/logo-tooltip.svg
similarity index 100%
rename from frontend/shared/assets/brand/modern-logo/logo-tooltip.svg
rename to frontend/editor/src/core/assets/brand/modern-logo/logo-tooltip.svg
diff --git a/frontend/shared/assets/brand/modern-logo/logo192.png b/frontend/editor/src/core/assets/brand/modern-logo/logo192.png
similarity index 100%
rename from frontend/shared/assets/brand/modern-logo/logo192.png
rename to frontend/editor/src/core/assets/brand/modern-logo/logo192.png
diff --git a/frontend/shared/assets/brand/modern-logo/logo512.png b/frontend/editor/src/core/assets/brand/modern-logo/logo512.png
similarity index 100%
rename from frontend/shared/assets/brand/modern-logo/logo512.png
rename to frontend/editor/src/core/assets/brand/modern-logo/logo512.png
diff --git a/frontend/shared/assets/login/AddToPDF.png b/frontend/editor/src/core/assets/login/AddToPDF.png
similarity index 100%
rename from frontend/shared/assets/login/AddToPDF.png
rename to frontend/editor/src/core/assets/login/AddToPDF.png
diff --git a/frontend/shared/assets/login/Firstpage.png b/frontend/editor/src/core/assets/login/Firstpage.png
similarity index 100%
rename from frontend/shared/assets/login/Firstpage.png
rename to frontend/editor/src/core/assets/login/Firstpage.png
diff --git a/frontend/shared/assets/login/LoginBackgroundPanel.png b/frontend/editor/src/core/assets/login/LoginBackgroundPanel.png
similarity index 100%
rename from frontend/shared/assets/login/LoginBackgroundPanel.png
rename to frontend/editor/src/core/assets/login/LoginBackgroundPanel.png
diff --git a/frontend/shared/assets/login/SecurePDF.png b/frontend/editor/src/core/assets/login/SecurePDF.png
similarity index 100%
rename from frontend/shared/assets/login/SecurePDF.png
rename to frontend/editor/src/core/assets/login/SecurePDF.png
diff --git a/frontend/shared/assets/login/apple.svg b/frontend/editor/src/core/assets/login/apple.svg
similarity index 100%
rename from frontend/shared/assets/login/apple.svg
rename to frontend/editor/src/core/assets/login/apple.svg
diff --git a/frontend/shared/assets/login/authentik.svg b/frontend/editor/src/core/assets/login/authentik.svg
similarity index 100%
rename from frontend/shared/assets/login/authentik.svg
rename to frontend/editor/src/core/assets/login/authentik.svg
diff --git a/frontend/shared/assets/login/cloudron.svg b/frontend/editor/src/core/assets/login/cloudron.svg
similarity index 100%
rename from frontend/shared/assets/login/cloudron.svg
rename to frontend/editor/src/core/assets/login/cloudron.svg
diff --git a/frontend/shared/assets/login/github.svg b/frontend/editor/src/core/assets/login/github.svg
similarity index 100%
rename from frontend/shared/assets/login/github.svg
rename to frontend/editor/src/core/assets/login/github.svg
diff --git a/frontend/shared/assets/login/google.svg b/frontend/editor/src/core/assets/login/google.svg
similarity index 100%
rename from frontend/shared/assets/login/google.svg
rename to frontend/editor/src/core/assets/login/google.svg
diff --git a/frontend/shared/assets/login/keycloak.svg b/frontend/editor/src/core/assets/login/keycloak.svg
similarity index 100%
rename from frontend/shared/assets/login/keycloak.svg
rename to frontend/editor/src/core/assets/login/keycloak.svg
diff --git a/frontend/shared/assets/login/microsoft.svg b/frontend/editor/src/core/assets/login/microsoft.svg
similarity index 100%
rename from frontend/shared/assets/login/microsoft.svg
rename to frontend/editor/src/core/assets/login/microsoft.svg
diff --git a/frontend/shared/assets/login/oidc.svg b/frontend/editor/src/core/assets/login/oidc.svg
similarity index 100%
rename from frontend/shared/assets/login/oidc.svg
rename to frontend/editor/src/core/assets/login/oidc.svg
diff --git a/frontend/shared/auth/ui/oauthIcons.ts b/frontend/editor/src/core/auth/ui/oauthIcons.ts
similarity index 67%
rename from frontend/shared/auth/ui/oauthIcons.ts
rename to frontend/editor/src/core/auth/ui/oauthIcons.ts
index c588274b92..f0d879786f 100644
--- a/frontend/shared/auth/ui/oauthIcons.ts
+++ b/frontend/editor/src/core/auth/ui/oauthIcons.ts
@@ -6,14 +6,14 @@
* editor's saas/desktop login buttons, and the config provider list) share one
* copy that works in both the editor and the portal bundles.
*/
-import googleIcon from "@shared/assets/login/google.svg";
-import githubIcon from "@shared/assets/login/github.svg";
-import appleIcon from "@shared/assets/login/apple.svg";
-import microsoftIcon from "@shared/assets/login/microsoft.svg";
-import keycloakIcon from "@shared/assets/login/keycloak.svg";
-import cloudronIcon from "@shared/assets/login/cloudron.svg";
-import authentikIcon from "@shared/assets/login/authentik.svg";
-import oidcIcon from "@shared/assets/login/oidc.svg";
+import googleIcon from "@app/assets/login/google.svg";
+import githubIcon from "@app/assets/login/github.svg";
+import appleIcon from "@app/assets/login/apple.svg";
+import microsoftIcon from "@app/assets/login/microsoft.svg";
+import keycloakIcon from "@app/assets/login/keycloak.svg";
+import cloudronIcon from "@app/assets/login/cloudron.svg";
+import authentikIcon from "@app/assets/login/authentik.svg";
+import oidcIcon from "@app/assets/login/oidc.svg";
/** Generic fallback icon (filename) for unknown providers. */
export const GENERIC_PROVIDER_ICON = "oidc.svg";
diff --git a/frontend/editor/src/core/components/shared/ThemeProvider.tsx b/frontend/editor/src/core/components/shared/ThemeProvider.tsx
index 2dda9b8d39..bf15ce187f 100644
--- a/frontend/editor/src/core/components/shared/ThemeProvider.tsx
+++ b/frontend/editor/src/core/components/shared/ThemeProvider.tsx
@@ -18,8 +18,8 @@ import {
getSystemTheme,
resolveColorScheme,
} from "@app/constants/theme";
-// SUI shared design-system tokens (used by @shared/components); key on `data-theme`.
-import "@shared/tokens/tokens.css";
+// SUI shared design-system tokens (used by @app/ui); key on `data-theme`.
+import "@app/tokens/tokens.css";
interface ThemeContextType {
themeMode: ThemeMode;
diff --git a/frontend/editor/src/core/components/shared/ToolPanelHeader.css b/frontend/editor/src/core/components/shared/ToolPanelHeader.css
new file mode 100644
index 0000000000..f1d99d1913
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/ToolPanelHeader.css
@@ -0,0 +1,93 @@
+/* ===================== PanelHeader (core) ===================== */
+/* Header for the active-tool rail. Core-owned so the OSS build has no */
+/* design-system dependency; mirrors the shared rail header treatment so it */
+/* reads well in both light and dark mode (thin border, no heavy fill). */
+
+.sui-panelhdr {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 1rem 1rem 0.75rem;
+ flex-shrink: 0;
+}
+
+.sui-panelhdr__bar {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 1;
+ min-width: 0;
+ padding: 0.4rem 0.75rem 0.4rem 0.4rem;
+ border: 1px solid var(--border-subtle, var(--mantine-color-default-border));
+ border-radius: 9999px;
+ background: var(--mantine-color-body);
+ text-align: left;
+ color: inherit;
+}
+
+.sui-panelhdr__icon {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ border-radius: 9999px;
+ background: var(--mantine-color-blue-light);
+ color: var(--mantine-color-blue-filled);
+ flex-shrink: 0;
+}
+
+.sui-panelhdr__icon svg {
+ font-size: 1rem;
+ width: 1rem;
+ height: 1rem;
+}
+
+/* ToolIcon wraps its glyph in .tool-button-icon with its own margin/transform;
+ reset them so the glyph sits dead-centre in the circular badge. */
+.sui-panelhdr__icon .tool-button-icon {
+ margin: 0 !important;
+ transform: none !important;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+}
+
+.sui-panelhdr__label {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.9rem;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--mantine-color-text);
+}
+
+/* Dark mode: let the header blend into the rail — just a thin border, no fill —
+ so it doesn't read as a clashing lighter card on the dark toolbar. */
+[data-mantine-color-scheme="dark"] .sui-panelhdr__bar {
+ background: transparent;
+ border-color: var(--border-subtle, var(--mantine-color-default-border));
+}
+
+[data-mantine-color-scheme="dark"] .sui-panelhdr__icon {
+ background: color-mix(
+ in srgb,
+ var(--mantine-color-blue-filled) 18%,
+ transparent
+ );
+ color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
+}
+
+/* Dark mode: the subtle gray close button is too dim against the dark rail —
+ brighten it to a clearly-visible light grey (near-white on hover). */
+[data-mantine-color-scheme="dark"] .sui-panelhdr__close {
+ color: var(--mantine-color-gray-4);
+}
+
+[data-mantine-color-scheme="dark"] .sui-panelhdr__close:hover {
+ color: var(--mantine-color-gray-2);
+}
diff --git a/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx b/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx
new file mode 100644
index 0000000000..e34ca4c6c2
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx
@@ -0,0 +1,53 @@
+import type { ReactNode } from "react";
+import { ActionIcon } from "@mantine/core";
+import CloseIcon from "@mui/icons-material/Close";
+import "@app/components/shared/ToolPanelHeader.css";
+
+export interface ToolPanelHeaderProps {
+ /** Glyph rendered in the tinted circular badge at the header's leading edge. */
+ icon: ReactNode;
+ /** Header title. */
+ title: ReactNode;
+ /** Close (X) handler. The trailing close button renders only when supplied. */
+ onClose?: () => void;
+ /** aria-label for the close button. */
+ closeLabel?: string;
+}
+
+/**
+ * Header for the active-tool rail surface: a tinted icon badge + title in a
+ * rounded bar with a trailing close button.
+ *
+ * Core-owned so the OSS build carries no design-system dependency. It mirrors
+ * the shared rail header's styling for the simple (non-menu) case the tool panel
+ * needs; the richer variant (dropdown menu, accents, loading dot) lives in the
+ * design system for the AI chat and Policies surfaces.
+ */
+export function ToolPanelHeader({
+ icon,
+ title,
+ onClose,
+ closeLabel,
+}: ToolPanelHeaderProps) {
+ return (
+
+
+ {icon}
+ {title}
+
+ {onClose && (
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts b/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts
index 6b5983822f..72dc675f8f 100644
--- a/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts
+++ b/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
-import { oauthIconUrl } from "@shared/auth/ui/oauthIcons";
+import { oauthIconUrl } from "@app/auth/ui/oauthIcons";
export type ProviderType = "oauth2" | "saml2" | "telegram" | "googledrive";
diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx
index bfd98d7b32..7e3aa04629 100644
--- a/frontend/editor/src/core/components/tools/RightSidebar.tsx
+++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx
@@ -19,7 +19,7 @@ import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import { useToolSections } from "@app/hooks/useToolSections";
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
import { ToolIcon } from "@app/components/shared/ToolIcon";
-import { PanelHeader } from "@shared/components/PanelHeader";
+import { ToolPanelHeader } from "@app/components/shared/ToolPanelHeader";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { withViewTransition } from "@app/utils/viewTransition";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
@@ -291,7 +291,7 @@ export default function RightSidebar() {
<>
{!showPolicies &&
(activeTool ? (
- {
describe("env vars", () => {
it("every VITE_ var used in source is present in an example env file", () => {
const baseEnv = readFileSync(join(frontendRoot, ".env"), "utf-8");
+ const proprietaryEnv = readFileSync(
+ join(frontendRoot, ".env.proprietary"),
+ "utf-8",
+ );
const desktopEnv = readFileSync(
join(frontendRoot, ".env.desktop"),
"utf-8",
@@ -58,6 +62,7 @@ describe("env vars", () => {
const declaredKeys = new Set([
...parseEnvKeys(baseEnv),
+ ...parseEnvKeys(proprietaryEnv),
...parseEnvKeys(desktopEnv),
...parseEnvKeys(saasEnv),
]);
diff --git a/frontend/editor/src/core/hooks/useLogoAssets.test.ts b/frontend/editor/src/core/hooks/useLogoAssets.test.ts
index b22701dedd..3d83fb35f4 100644
--- a/frontend/editor/src/core/hooks/useLogoAssets.test.ts
+++ b/frontend/editor/src/core/hooks/useLogoAssets.test.ts
@@ -10,11 +10,11 @@ import type { LogoVariant } from "@app/services/preferencesService";
*/
describe("useLogoAssets - Logo Asset Files", () => {
const publicDir = path.resolve(__dirname, "../../../public");
- // Brand logo assets live in the shared design system; the editor's vite
- // config copies shared/assets/brand//* into the served root at build
- // time (see viteStaticCopy in editor/vite.config.ts), so useLogoAssets can
- // keep referencing them by their public-URL path. Validate them at source.
- const brandDir = path.resolve(__dirname, "../../../../shared/assets/brand");
+ // Brand logo assets live in core; the editor's vite config copies
+ // core/assets/brand//* into the served root at build time (see
+ // viteStaticCopy in editor/vite.config.ts), so useLogoAssets can keep
+ // referencing them by their public-URL path. Validate them at source.
+ const brandDir = path.resolve(__dirname, "../assets/brand");
// All asset files that useLogoAssets references
const requiredAssets = [
diff --git a/frontend/editor/src/core/i18n.ts b/frontend/editor/src/core/i18n.ts
index 82b860b467..9663a1c2e2 100644
--- a/frontend/editor/src/core/i18n.ts
+++ b/frontend/editor/src/core/i18n.ts
@@ -1,7 +1,7 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
-import TomlBackend from "@shared/i18n/tomlBackend";
+import TomlBackend from "@app/i18n/tomlBackend";
import {
supportedLanguages,
rtlLanguages,
@@ -10,10 +10,10 @@ import {
normalizeLanguageCode,
toUnderscoreFormat,
toUnderscoreLanguages,
-} from "@shared/i18n/languages";
+} from "@app/i18n/languages";
// Language metadata and code helpers are shared with the portal via
-// @shared/i18n. Re-export them so existing `@app/i18n` consumers are unchanged.
+// @app/i18n. Re-export them so existing `@app/i18n` consumers are unchanged.
export {
supportedLanguages,
rtlLanguages,
diff --git a/frontend/editor/src/core/i18n/config.ts b/frontend/editor/src/core/i18n/config.ts
index 47e028c1f4..02b79f06b1 100644
--- a/frontend/editor/src/core/i18n/config.ts
+++ b/frontend/editor/src/core/i18n/config.ts
@@ -1,6 +1,6 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
-import TomlBackend from "@shared/i18n/tomlBackend";
+import TomlBackend from "@app/i18n/tomlBackend";
i18n
.use(TomlBackend)
diff --git a/frontend/shared/i18n/languages.ts b/frontend/editor/src/core/i18n/languages.ts
similarity index 100%
rename from frontend/shared/i18n/languages.ts
rename to frontend/editor/src/core/i18n/languages.ts
diff --git a/frontend/shared/i18n/tomlBackend.ts b/frontend/editor/src/core/i18n/tomlBackend.ts
similarity index 100%
rename from frontend/shared/i18n/tomlBackend.ts
rename to frontend/editor/src/core/i18n/tomlBackend.ts
diff --git a/frontend/shared/i18n/translationAudit.ts b/frontend/editor/src/core/i18n/translationAudit.ts
similarity index 90%
rename from frontend/shared/i18n/translationAudit.ts
rename to frontend/editor/src/core/i18n/translationAudit.ts
index 50cd1b2c76..e2cc309c72 100644
--- a/frontend/shared/i18n/translationAudit.ts
+++ b/frontend/editor/src/core/i18n/translationAudit.ts
@@ -19,8 +19,8 @@ import { fileURLToPath } from "url";
import ts from "typescript";
import { parse } from "smol-toml";
-const HERE = path.dirname(fileURLToPath(import.meta.url)); // frontend/shared/i18n
-const FRONTEND_ROOT = path.resolve(HERE, "../.."); // frontend
+const HERE = path.dirname(fileURLToPath(import.meta.url)); // frontend/editor/src/core/i18n
+const FRONTEND_ROOT = path.resolve(HERE, "../../../.."); // frontend
/** Repo root, for rendering source paths relative in CI annotations. */
export const REPO_ROOT = path.resolve(FRONTEND_ROOT, "..");
@@ -29,12 +29,6 @@ export interface TranslationProject {
name: string;
/** Absolute path to the source tree to scan. */
srcRoot: string;
- /**
- * Extra source trees that also contribute used keys (e.g. the shared layer
- * (frontend/shared), whose components (login UI, etc.) are rendered by both
- * apps and reference each app's locale keys).
- */
- extraRoots?: string[];
/** Absolute path to the en-US source locale. */
localeFile: string;
/** Static keys flagged as missing that are genuinely fine (false positives). */
@@ -68,11 +62,6 @@ const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/;
const front = (rel: string): string => path.join(FRONTEND_ROOT, rel);
-// The shared layer is rendered by both apps, so its translation usage counts
-// toward each project (or keys used only from shared look unused, and a shared
-// component's keys go unvalidated against an app's locale).
-const SHARED_SRC = front("shared");
-
/**
* The projects the i18n suites guard. Each carries its own ignore lists: the
* editor exempts a few runtime-assembled key families; the portal starts clean.
@@ -80,8 +69,11 @@ const SHARED_SRC = front("shared");
export const I18N_PROJECTS: TranslationProject[] = [
{
name: "editor",
+ // One project over the whole editor tree, including the portal layer. Portal
+ // strings live under portal.* in the shared locale and are referenced with
+ // that prefix in source (t("portal.users.title")), so they validate here
+ // like any other key.
srcRoot: front("editor/src"),
- extraRoots: [SHARED_SRC],
localeFile: front("editor/public/locales/en-US/translation.toml"),
ignoredKeyPatterns: [
// SignSettings / SavedSignaturesSection resolve every key as
@@ -93,24 +85,14 @@ export const I18N_PROJECTS: TranslationProject[] = [
/^account\./,
// [language] direction is read by the i18n layer, never as a UI string.
/^language\.direction$/,
+ // Portal source-type copy is referenced via metadata keys in
+ // components/sources/sourceTypes.ts (t(field.labelKey)), invisible to the
+ // static scan.
+ /^portal\.sources\.types\./,
],
minUsedKeys: 100,
minLocaleKeys: 100,
},
- {
- name: "portal",
- srcRoot: front("portal/src"),
- extraRoots: [SHARED_SRC],
- localeFile: front("portal/public/locales/en-US/translation.toml"),
- ignoredKeyPatterns: [
- // Source-type copy is referenced via metadata keys in
- // components/sources/sourceTypes.ts (t(field.labelKey)), so the static
- // scan can't see these as used.
- /^sources\.types\./,
- ],
- minUsedKeys: 20,
- minLocaleKeys: 20,
- },
];
// Locale tables are shallow by design; the cap only trips on a pathological
@@ -189,11 +171,6 @@ const listSourceFiles = (roots: string[]): string[] =>
)
.filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file)));
-const projectRoots = (project: TranslationProject): string[] => [
- project.srcRoot,
- ...(project.extraRoots ?? []),
-];
-
const hasPluralCoverage = (key: string, available: Set): boolean =>
[...available].some(
(k) => k.startsWith(`${key}_`) && PLURAL_SUFFIX_RE.test(k),
@@ -347,7 +324,7 @@ export function findMissingKeys(project: TranslationProject): {
return false;
};
- const used = listSourceFiles(projectRoots(project))
+ const used = listSourceFiles([project.srcRoot])
.flatMap(extractStaticKeys)
.filter(({ key }) => !ignored.has(key));
const missing = used.filter(({ key }) => !resolves(key));
@@ -360,7 +337,7 @@ export function findUnusedKeys(project: TranslationProject): {
localeCount: number;
} {
const localeKeys = Array.from(collectLocaleKeys(project.localeFile));
- const files = listSourceFiles(projectRoots(project));
+ const files = listSourceFiles([project.srcRoot]);
const source = files.map((file) => fs.readFileSync(file, "utf8")).join("\n");
const shapes = new Set();
diff --git a/frontend/editor/src/core/routes/adminRouteExtensions.tsx b/frontend/editor/src/core/routes/adminRouteExtensions.tsx
new file mode 100644
index 0000000000..657d4799a3
--- /dev/null
+++ b/frontend/editor/src/core/routes/adminRouteExtensions.tsx
@@ -0,0 +1,10 @@
+import type { ReactElement } from "react";
+
+/**
+ * Admin-only route-set contributed by higher layers (the portal). The OSS core
+ * build ships none, so this stub returns an empty list and the portal chunk is
+ * never referenced in the core bundle.
+ */
+export function getAdminRouteExtensions(): ReactElement[] {
+ return [];
+}
diff --git a/frontend/editor/src/core/tests/missingTranslations.test.ts b/frontend/editor/src/core/tests/missingTranslations.test.ts
index f98022559b..617699e43a 100644
--- a/frontend/editor/src/core/tests/missingTranslations.test.ts
+++ b/frontend/editor/src/core/tests/missingTranslations.test.ts
@@ -5,10 +5,10 @@ import {
I18N_PROJECTS,
REPO_ROOT,
findMissingKeys,
-} from "@shared/i18n/translationAudit";
+} from "@app/i18n/translationAudit";
// One suite per frontend app (editor + portal). The scan logic lives in
-// @shared/i18n/translationAudit so both apps share one implementation.
+// @app/i18n/translationAudit so both apps share one implementation.
describe.each(I18N_PROJECTS)(
"Missing translation coverage — $name",
(project) => {
diff --git a/frontend/editor/src/core/tests/unusedTranslations.test.ts b/frontend/editor/src/core/tests/unusedTranslations.test.ts
index 1193c737bf..f0d0fbce93 100644
--- a/frontend/editor/src/core/tests/unusedTranslations.test.ts
+++ b/frontend/editor/src/core/tests/unusedTranslations.test.ts
@@ -5,10 +5,10 @@ import {
I18N_PROJECTS,
REPO_ROOT,
findUnusedKeys,
-} from "@shared/i18n/translationAudit";
+} from "@app/i18n/translationAudit";
// One suite per frontend app (editor + portal). The scan logic lives in
-// @shared/i18n/translationAudit so both apps share one implementation; each
+// @app/i18n/translationAudit so both apps share one implementation; each
// project carries its own ignoredKeyPatterns for runtime-assembled keys.
describe.each(I18N_PROJECTS)(
"Unused translation coverage — $name",
@@ -38,7 +38,7 @@ describe.each(I18N_PROJECTS)(
`Found ${unused.length} unused en-US translation key(s). ` +
`Remove them from ${localeRelative}, or (if the usage is too ` +
`dynamic for the heuristic to spot) add a pattern to this ` +
- `project's ignoredKeyPatterns in @shared/i18n/translationAudit.`,
+ `project's ignoredKeyPatterns in @app/i18n/translationAudit.`,
).toEqual([]);
},
);
diff --git a/frontend/shared/tokens/Tokens.stories.tsx b/frontend/editor/src/core/tokens/Tokens.stories.tsx
similarity index 100%
rename from frontend/shared/tokens/Tokens.stories.tsx
rename to frontend/editor/src/core/tokens/Tokens.stories.tsx
diff --git a/frontend/shared/tokens/base.css b/frontend/editor/src/core/tokens/base.css
similarity index 100%
rename from frontend/shared/tokens/base.css
rename to frontend/editor/src/core/tokens/base.css
diff --git a/frontend/shared/tokens/tokens.css b/frontend/editor/src/core/tokens/tokens.css
similarity index 100%
rename from frontend/shared/tokens/tokens.css
rename to frontend/editor/src/core/tokens/tokens.css
diff --git a/frontend/shared/tokens/tokens.ts b/frontend/editor/src/core/tokens/tokens.ts
similarity index 100%
rename from frontend/shared/tokens/tokens.ts
rename to frontend/editor/src/core/tokens/tokens.ts
diff --git a/frontend/editor/src/core/tsconfig.json b/frontend/editor/src/core/tsconfig.json
index 781d894b6d..08bf588c6a 100644
--- a/frontend/editor/src/core/tsconfig.json
+++ b/frontend/editor/src/core/tsconfig.json
@@ -2,8 +2,7 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
- "@app/*": ["../../src/core/*"],
- "@shared/*": ["../../../shared/*"]
+ "@app/*": ["../../src/core/*"]
}
},
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."]
diff --git a/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx b/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx
index ad783e9f76..f5e64f2927 100644
--- a/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx
+++ b/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx
@@ -1,8 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import LoginRightCarousel from "@shared/auth/ui/LoginRightCarousel";
+import LoginRightCarousel from "@app/auth/ui/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
-import styles from "@shared/auth/ui/AuthShell.module.css";
+import styles from "@app/auth/ui/AuthShell.module.css";
import { useLogoVariant } from "@app/hooks/useLogoVariant";
interface DesktopAuthLayoutProps {
diff --git a/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx b/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx
index c2da65845b..c9813d818e 100644
--- a/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx
+++ b/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { authService, UserInfo } from "@app/services/authService";
import { buildOAuthCallbackHtml } from "@app/utils/oauthCallbackHtml";
-import { oauthIconUrl } from "@shared/auth/ui/oauthIcons";
+import { oauthIconUrl } from "@app/auth/ui/oauthIcons";
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import "@app/components/SetupWizard/desktopOAuth.css";
diff --git a/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx
index 7f679052a3..dc346ef9ac 100644
--- a/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx
+++ b/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx
@@ -1,13 +1,13 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import LoginHeader from "@app/routes/login/LoginHeader";
-import ErrorMessage from "@shared/auth/ui/ErrorMessage";
-import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm";
+import ErrorMessage from "@app/auth/ui/ErrorMessage";
+import EmailPasswordForm from "@app/auth/ui/EmailPasswordForm";
import DividerWithText from "@app/components/shared/DividerWithText";
import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons";
import { SelfHostedLink } from "@app/components/SetupWizard/SelfHostedLink";
import { UserInfo } from "@app/services/authService";
-import "@shared/auth/ui/auth.css";
+import "@app/auth/ui/auth.css";
interface SaaSLoginScreenProps {
serverUrl: string;
diff --git a/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx
index bcc7bf88c7..a3765c2c98 100644
--- a/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx
+++ b/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx
@@ -1,14 +1,14 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import LoginHeader from "@app/routes/login/LoginHeader";
-import ErrorMessage from "@shared/auth/ui/ErrorMessage";
+import ErrorMessage from "@app/auth/ui/ErrorMessage";
import SignupForm from "@app/routes/signup/SignupForm";
import {
useSignupFormValidation,
SignupFieldErrors,
} from "@app/routes/signup/SignupFormValidation";
import { authService } from "@app/services/authService";
-import "@shared/auth/ui/auth.css";
+import "@app/auth/ui/auth.css";
interface SaaSSignupScreenProps {
loading: boolean;
diff --git a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx
index 739c02516d..8d5c98fe6e 100644
--- a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx
+++ b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx
@@ -1,6 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
-import "@shared/auth/ui/auth.css";
+import "@app/auth/ui/auth.css";
interface SelfHostedLinkProps {
onClick: () => void;
diff --git a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx
index ff2d743809..34a26a8fa0 100644
--- a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx
+++ b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx
@@ -2,13 +2,13 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import LoginHeader from "@app/routes/login/LoginHeader";
-import ErrorMessage from "@shared/auth/ui/ErrorMessage";
-import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm";
+import ErrorMessage from "@app/auth/ui/ErrorMessage";
+import EmailPasswordForm from "@app/auth/ui/EmailPasswordForm";
import DividerWithText from "@app/components/shared/DividerWithText";
import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons";
import { UserInfo } from "@app/services/authService";
import { SSOProviderConfig } from "@app/services/connectionModeService";
-import "@shared/auth/ui/auth.css";
+import "@app/auth/ui/auth.css";
interface SelfHostedLoginScreenProps {
serverUrl: string;
diff --git a/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx
index 7ddf7b3cb2..c3fc2f3846 100644
--- a/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx
+++ b/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx
@@ -1,10 +1,10 @@
import React from "react";
import { useTranslation } from "react-i18next";
import LoginHeader from "@app/routes/login/LoginHeader";
-import ErrorMessage from "@shared/auth/ui/ErrorMessage";
+import ErrorMessage from "@app/auth/ui/ErrorMessage";
import { ServerSelection } from "@app/components/SetupWizard/ServerSelection";
import { ServerConfig } from "@app/services/connectionModeService";
-import "@shared/auth/ui/auth.css";
+import "@app/auth/ui/auth.css";
interface ServerSelectionScreenProps {
onSelect: (config: ServerConfig) => void;
diff --git a/frontend/editor/src/desktop/components/SetupWizard/index.tsx b/frontend/editor/src/desktop/components/SetupWizard/index.tsx
index e218f06e0c..2f6ddaba2b 100644
--- a/frontend/editor/src/desktop/components/SetupWizard/index.tsx
+++ b/frontend/editor/src/desktop/components/SetupWizard/index.tsx
@@ -19,7 +19,7 @@ import {
import { tauriBackendService } from "@app/services/tauriBackendService";
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import { listen } from "@tauri-apps/api/event";
-import "@shared/auth/ui/auth.css";
+import "@app/auth/ui/auth.css";
import { DisabledButtonWithTooltip } from "@app/components/shared/DisabledButtonWithTooltip";
enum SetupStep {
diff --git a/frontend/editor/src/desktop/routes/adminRouteExtensions.tsx b/frontend/editor/src/desktop/routes/adminRouteExtensions.tsx
new file mode 100644
index 0000000000..afd92050bc
--- /dev/null
+++ b/frontend/editor/src/desktop/routes/adminRouteExtensions.tsx
@@ -0,0 +1,10 @@
+import type { ReactElement } from "react";
+
+/**
+ * Desktop inherits proprietary's App but must NOT ship the portal. Shadowing
+ * the seam back to empty means the desktop bundle never references PortalApp,
+ * so the portal chunk is not emitted (and @portal never has to resolve there).
+ */
+export function getAdminRouteExtensions(): ReactElement[] {
+ return [];
+}
diff --git a/frontend/editor/src/desktop/tsconfig.json b/frontend/editor/src/desktop/tsconfig.json
index 27f094be66..e7f83893c4 100644
--- a/frontend/editor/src/desktop/tsconfig.json
+++ b/frontend/editor/src/desktop/tsconfig.json
@@ -10,8 +10,7 @@
],
"@cloud/*": ["../../src/cloud/*"],
"@proprietary/*": ["../../src/proprietary/*"],
- "@core/*": ["../../src/core/*"],
- "@shared/*": ["../../../shared/*"]
+ "@core/*": ["../../src/core/*"]
}
},
"include": [
diff --git a/frontend/portal/LICENSE b/frontend/editor/src/portal/LICENSE
similarity index 100%
rename from frontend/portal/LICENSE
rename to frontend/editor/src/portal/LICENSE
diff --git a/frontend/portal/MOCKS.md b/frontend/editor/src/portal/MOCKS.md
similarity index 100%
rename from frontend/portal/MOCKS.md
rename to frontend/editor/src/portal/MOCKS.md
diff --git a/frontend/portal/src/App.tsx b/frontend/editor/src/portal/PortalApp.tsx
similarity index 81%
rename from frontend/portal/src/App.tsx
rename to frontend/editor/src/portal/PortalApp.tsx
index cd1195cde3..1494e23cdf 100644
--- a/frontend/portal/src/App.tsx
+++ b/frontend/editor/src/portal/PortalApp.tsx
@@ -1,12 +1,12 @@
import { useEffect, type ReactNode } from "react";
-import { BrowserRouter, useLocation } from "react-router-dom";
+import { useLocation } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
-import { AuthProvider } from "@shared/auth";
+import { AuthProvider } from "@app/auth";
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
import { TierProvider } from "@portal/contexts/TierContext";
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
-import type { SupabaseLoginSession } from "@shared/auth/ui/useSupabaseLogin";
+import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin";
import { UIProvider, useUI } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme";
import { AppShell } from "@portal/components/AppShell";
@@ -21,6 +21,8 @@ import {
useAccountLinkContext,
} from "@portal/contexts/AccountLinkContext";
import { ViewRouter } from "@portal/ViewRouter";
+// Reset + typography, scoped to .portal-scope below.
+import "@portal/theme/base.css";
/**
* Binds Mantine's colour scheme to the portal's own ThemeProvider so Mantine
@@ -117,22 +119,24 @@ function RoutedContent() {
);
}
-export function App() {
- // Honour the Vite base path so the portal routes correctly when served under a
- // subpath (e.g. "/portal" behind the single-origin proxy). BASE_URL is "./"
- // for a standalone build, which isn't a valid router basename, so only pass it
- // when it's an absolute subpath.
- const baseUrl = import.meta.env.BASE_URL;
- const basename = baseUrl.startsWith("/") ? baseUrl : undefined;
+/**
+ * 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
+ * is the one and only router; the portal's routes are relative
+ * to the /portal mount (see ViewRouter).
+ */
+export function PortalApp() {
return (
-
-
- {/* TierProvider sits INSIDE LinkProvider so it can derive the tier
+ {/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
+
+
+
+ {/* TierProvider sits INSIDE LinkProvider so it can derive the tier
from the real link/subscription state when MSW mocks are off. */}
-
-
+
@@ -148,10 +152,10 @@ export function App() {
-
-
-
-
+
+
+
+
);
diff --git a/frontend/portal/UI_CONVENTIONS.md b/frontend/editor/src/portal/UI_CONVENTIONS.md
similarity index 95%
rename from frontend/portal/UI_CONVENTIONS.md
rename to frontend/editor/src/portal/UI_CONVENTIONS.md
index c4d51b5819..40879e2199 100644
--- a/frontend/portal/UI_CONVENTIONS.md
+++ b/frontend/editor/src/portal/UI_CONVENTIONS.md
@@ -3,7 +3,7 @@
The portal has two component sources. The rule:
> **Simple, presentational, brand-defining UI → our SUI design system
-> (`@shared/components`). Complex, stateful, or accessibility-hard widgets →
+> (`@app/ui`). Complex, stateful, or accessibility-hard widgets →
> Mantine.** Don't reinvent what Mantine already does well; do own the look of
> the simple, high-frequency pieces.
@@ -13,7 +13,7 @@ follow the same light/dark switch and brand colours as SUI. **The provider is
intentional** — it exists precisely so we can drop Mantine widgets in where they
earn their keep.
-## Use SUI (`@shared/components`) — our own style
+## Use SUI (`@app/ui`) — our own style
Layout and presentational primitives we want full brand control over and that
are cheap to own:
diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx
new file mode 100644
index 0000000000..2b09a24507
--- /dev/null
+++ b/frontend/editor/src/portal/ViewRouter.tsx
@@ -0,0 +1,58 @@
+import { Navigate, Route, Routes } from "react-router-dom";
+import { Home } from "@portal/views/Home";
+import { Users } from "@portal/views/Users";
+import { Documents } from "@portal/views/Documents";
+import { Pipelines } from "@portal/views/Pipelines";
+import { Sources } from "@portal/views/Sources";
+import { AgentBuilder } from "@portal/views/AgentBuilder";
+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 { DeveloperDocs } from "@portal/views/DeveloperDocs";
+import { Procurement } from "@portal/views/Procurement";
+import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
+
+// The portal mounts as a route-set under /portal/* in the editor app, so these
+// child routes are relative to that base: strip the leading slash from the
+// logical VIEW_PATHS, and home is the index route. Redirects use toPortalPath
+// so they resolve to the portal, not the editor root.
+const rel = (viewPath: string) => viewPath.replace(/^\//, "");
+
+export function ViewRouter() {
+ return (
+
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+ {/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
+ }
+ />
+ {/* Settings is a modal overlay, not a route (see AppShell + UIContext). */}
+ {/* Unknown paths land on Home. */}
+ }
+ />
+
+ );
+}
diff --git a/frontend/portal/src/api/agents.ts b/frontend/editor/src/portal/api/agents.ts
similarity index 100%
rename from frontend/portal/src/api/agents.ts
rename to frontend/editor/src/portal/api/agents.ts
diff --git a/frontend/portal/src/api/assistant.ts b/frontend/editor/src/portal/api/assistant.ts
similarity index 100%
rename from frontend/portal/src/api/assistant.ts
rename to frontend/editor/src/portal/api/assistant.ts
diff --git a/frontend/portal/src/api/billing.ts b/frontend/editor/src/portal/api/billing.ts
similarity index 95%
rename from frontend/portal/src/api/billing.ts
rename to frontend/editor/src/portal/api/billing.ts
index 7c87bb5ca5..fbb8673a26 100644
--- a/frontend/portal/src/api/billing.ts
+++ b/frontend/editor/src/portal/api/billing.ts
@@ -1,10 +1,10 @@
import { apiClient } from "@portal/api/http";
-import type { Wallet } from "@shared/billing";
+import type { Wallet } from "@app/billing";
/**
* Real wallet + billing surface. All calls go to apiClient.saas — the hosted
* SaaS Java backend, authed by the admin's Supabase JWT. The wallet contract
- * itself lives in {@code @shared/billing} (shared with the editor cloud surface).
+ * itself lives in {@code @app/billing} (shared with the editor cloud surface).
*/
// Re-export the shared contract so existing `@portal/api/billing` importers keep working.
@@ -15,7 +15,7 @@ export type {
WalletMember,
WalletCategoryBreakdown,
WalletActivityRow,
-} from "@shared/billing";
+} from "@app/billing";
export async function fetchWallet(): Promise {
return apiClient.saas.json("/api/v1/payg/wallet");
diff --git a/frontend/portal/src/api/docs.ts b/frontend/editor/src/portal/api/docs.ts
similarity index 100%
rename from frontend/portal/src/api/docs.ts
rename to frontend/editor/src/portal/api/docs.ts
diff --git a/frontend/portal/src/api/documents.ts b/frontend/editor/src/portal/api/documents.ts
similarity index 100%
rename from frontend/portal/src/api/documents.ts
rename to frontend/editor/src/portal/api/documents.ts
diff --git a/frontend/portal/src/api/editorDeploy.ts b/frontend/editor/src/portal/api/editorDeploy.ts
similarity index 100%
rename from frontend/portal/src/api/editorDeploy.ts
rename to frontend/editor/src/portal/api/editorDeploy.ts
diff --git a/frontend/portal/src/api/home.ts b/frontend/editor/src/portal/api/home.ts
similarity index 100%
rename from frontend/portal/src/api/home.ts
rename to frontend/editor/src/portal/api/home.ts
diff --git a/frontend/portal/src/api/http.test.ts b/frontend/editor/src/portal/api/http.test.ts
similarity index 96%
rename from frontend/portal/src/api/http.test.ts
rename to frontend/editor/src/portal/api/http.test.ts
index 085948c77f..031327c0cf 100644
--- a/frontend/portal/src/api/http.test.ts
+++ b/frontend/editor/src/portal/api/http.test.ts
@@ -12,8 +12,8 @@ const { getSession, getStoredTokenMock } = vi.hoisted(() => ({
getStoredTokenMock: vi.fn(),
}));
-vi.mock("@shared/auth", () => ({ getStoredToken: getStoredTokenMock }));
-vi.mock("@shared/auth/supabase/supabaseClient", () => ({
+vi.mock("@app/auth", () => ({ getStoredToken: getStoredTokenMock }));
+vi.mock("@app/auth/supabase/supabaseClient", () => ({
getSupabaseClient: () => ({ auth: { getSession } }),
configureSupabase: vi.fn(),
}));
diff --git a/frontend/portal/src/api/http.ts b/frontend/editor/src/portal/api/http.ts
similarity index 97%
rename from frontend/portal/src/api/http.ts
rename to frontend/editor/src/portal/api/http.ts
index a37df3ce81..9f7b28da03 100644
--- a/frontend/portal/src/api/http.ts
+++ b/frontend/editor/src/portal/api/http.ts
@@ -5,7 +5,7 @@
*
* apiClient.local Same-origin (vite proxy → this instance's local
* Stirling backend on :8080). Spring admin bearer
- * (`stirling_jwt` from @shared/auth) auto-attached.
+ * (`stirling_jwt` from @app/auth) auto-attached.
* USE FOR: actions on this instance —
* /api/v1/account-link/{status,link,unlink}, etc.
*
@@ -39,8 +39,8 @@
* entitlement calls. It never enters the portal — the browser is the human
* admin and uses the Supabase JWT for SaaS reads. Don't add it here.
*/
-import { clearStoredToken, getStoredToken } from "@shared/auth";
-import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
+import { clearStoredToken, getStoredToken } from "@app/auth";
+import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
/** Read the SaaS base URL at call time so tests can stub it via vi.stubEnv. */
diff --git a/frontend/portal/src/api/infrastructure.ts b/frontend/editor/src/portal/api/infrastructure.ts
similarity index 100%
rename from frontend/portal/src/api/infrastructure.ts
rename to frontend/editor/src/portal/api/infrastructure.ts
diff --git a/frontend/portal/src/api/link.test.ts b/frontend/editor/src/portal/api/link.test.ts
similarity index 98%
rename from frontend/portal/src/api/link.test.ts
rename to frontend/editor/src/portal/api/link.test.ts
index e6d9b12703..2f5260486a 100644
--- a/frontend/portal/src/api/link.test.ts
+++ b/frontend/editor/src/portal/api/link.test.ts
@@ -20,7 +20,7 @@ const { getSession } = vi.hoisted(() => ({
data: { session: { access_token: "supabase_jwt_test" } },
}),
}));
-vi.mock("@shared/auth/supabase/supabaseClient", () => ({
+vi.mock("@app/auth/supabase/supabaseClient", () => ({
getSupabaseClient: () => ({ auth: { getSession } }),
configureSupabase: vi.fn(),
}));
diff --git a/frontend/portal/src/api/link.ts b/frontend/editor/src/portal/api/link.ts
similarity index 100%
rename from frontend/portal/src/api/link.ts
rename to frontend/editor/src/portal/api/link.ts
diff --git a/frontend/portal/src/api/notifications.ts b/frontend/editor/src/portal/api/notifications.ts
similarity index 100%
rename from frontend/portal/src/api/notifications.ts
rename to frontend/editor/src/portal/api/notifications.ts
diff --git a/frontend/portal/src/api/ops.ts b/frontend/editor/src/portal/api/ops.ts
similarity index 100%
rename from frontend/portal/src/api/ops.ts
rename to frontend/editor/src/portal/api/ops.ts
diff --git a/frontend/portal/src/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts
similarity index 100%
rename from frontend/portal/src/api/pipelines.ts
rename to frontend/editor/src/portal/api/pipelines.ts
diff --git a/frontend/portal/src/api/policies.ts b/frontend/editor/src/portal/api/policies.ts
similarity index 95%
rename from frontend/portal/src/api/policies.ts
rename to frontend/editor/src/portal/api/policies.ts
index 70eccd5cfb..e225449a16 100644
--- a/frontend/portal/src/api/policies.ts
+++ b/frontend/editor/src/portal/api/policies.ts
@@ -11,9 +11,9 @@
*/
import { apiClient } from "@portal/api/http";
-import { fromWirePolicy, toWirePolicy } from "@shared/policies/codec";
-import { runsToActivity, runsToStats } from "@shared/policies/runs";
-import type { PolicyDecodedState, WirePolicy } from "@shared/policies/types";
+import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
+import { runsToActivity, runsToStats } from "@app/policies/runs";
+import type { PolicyDecodedState, WirePolicy } from "@app/policies/types";
import {
POLICY_CATEGORIES,
POLICY_CONFIG,
@@ -25,7 +25,7 @@ import {
type PolicyState,
type PolicyStatus,
} from "@portal/mocks/policies";
-import type { PolicyRunView } from "@shared/policies/types";
+import type { PolicyRunView } from "@app/policies/types";
export type {
CatalogueEntry,
@@ -58,7 +58,7 @@ export {
} from "@portal/mocks/policies";
// Re-export the wire step type under the legacy name components depend on.
-export type { WirePipelineStep as PipelineStep } from "@shared/policies/types";
+export type { WirePipelineStep as PipelineStep } from "@app/policies/types";
// ── Client-side catalogue assembly ───────────────────────────────────────────
diff --git a/frontend/portal/src/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts
similarity index 100%
rename from frontend/portal/src/api/procurement.ts
rename to frontend/editor/src/portal/api/procurement.ts
diff --git a/frontend/portal/src/api/sdkComponents.ts b/frontend/editor/src/portal/api/sdkComponents.ts
similarity index 100%
rename from frontend/portal/src/api/sdkComponents.ts
rename to frontend/editor/src/portal/api/sdkComponents.ts
diff --git a/frontend/portal/src/api/search.ts b/frontend/editor/src/portal/api/search.ts
similarity index 100%
rename from frontend/portal/src/api/search.ts
rename to frontend/editor/src/portal/api/search.ts
diff --git a/frontend/portal/src/api/settings.ts b/frontend/editor/src/portal/api/settings.ts
similarity index 100%
rename from frontend/portal/src/api/settings.ts
rename to frontend/editor/src/portal/api/settings.ts
diff --git a/frontend/portal/src/api/sources.ts b/frontend/editor/src/portal/api/sources.ts
similarity index 100%
rename from frontend/portal/src/api/sources.ts
rename to frontend/editor/src/portal/api/sources.ts
diff --git a/frontend/portal/src/api/users.ts b/frontend/editor/src/portal/api/users.ts
similarity index 100%
rename from frontend/portal/src/api/users.ts
rename to frontend/editor/src/portal/api/users.ts
diff --git a/frontend/portal/src/auth/editorUrl.ts b/frontend/editor/src/portal/auth/editorUrl.ts
similarity index 86%
rename from frontend/portal/src/auth/editorUrl.ts
rename to frontend/editor/src/portal/auth/editorUrl.ts
index 2cf77c75b1..43eb466cb7 100644
--- a/frontend/portal/src/auth/editorUrl.ts
+++ b/frontend/editor/src/portal/auth/editorUrl.ts
@@ -5,6 +5,6 @@
* 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 portal/.env.local.
+ * separately-running editor, set VITE_EDITOR_URL in editor/.env.local.
*/
export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL;
diff --git a/frontend/portal/src/auth/saasSupabase.ts b/frontend/editor/src/portal/auth/saasSupabase.ts
similarity index 97%
rename from frontend/portal/src/auth/saasSupabase.ts
rename to frontend/editor/src/portal/auth/saasSupabase.ts
index 25a8a6ed6b..813a2f7cad 100644
--- a/frontend/portal/src/auth/saasSupabase.ts
+++ b/frontend/editor/src/portal/auth/saasSupabase.ts
@@ -1,7 +1,7 @@
import {
configureSupabase,
getSupabaseClient,
-} from "@shared/auth/supabase/supabaseClient";
+} from "@app/auth/supabase/supabaseClient";
/**
* Configures the shared Supabase client against the hosted SaaS project so the
diff --git a/frontend/portal/src/auth/saasSupabaseLogin.test.ts b/frontend/editor/src/portal/auth/saasSupabaseLogin.test.ts
similarity index 96%
rename from frontend/portal/src/auth/saasSupabaseLogin.test.ts
rename to frontend/editor/src/portal/auth/saasSupabaseLogin.test.ts
index 5517897ec9..f1d991a3c0 100644
--- a/frontend/portal/src/auth/saasSupabaseLogin.test.ts
+++ b/frontend/editor/src/portal/auth/saasSupabaseLogin.test.ts
@@ -8,11 +8,11 @@ const { signInWithPassword, signInWithOAuth } = vi.hoisted(() => ({
signInWithOAuth: vi.fn(),
}));
-vi.mock("@shared/auth/supabase/supabaseClient", () => ({
+vi.mock("@app/auth/supabase/supabaseClient", () => ({
getSupabaseClient: () => ({ auth: { signInWithPassword, signInWithOAuth } }),
}));
-import { useSupabaseLogin } from "@shared/auth/ui/useSupabaseLogin";
+import { useSupabaseLogin } from "@app/auth/ui/useSupabaseLogin";
describe("useSupabaseLogin (in-app account-link login)", () => {
beforeEach(() => {
diff --git a/frontend/portal/src/billing/sharedBillingFormat.test.ts b/frontend/editor/src/portal/billing/sharedBillingFormat.test.ts
similarity index 96%
rename from frontend/portal/src/billing/sharedBillingFormat.test.ts
rename to frontend/editor/src/portal/billing/sharedBillingFormat.test.ts
index 8882df796b..d7ccd22df7 100644
--- a/frontend/portal/src/billing/sharedBillingFormat.test.ts
+++ b/frontend/editor/src/portal/billing/sharedBillingFormat.test.ts
@@ -5,10 +5,10 @@ import {
formatMinor,
formatPeriodDate,
meterState,
-} from "@shared/billing";
+} from "@app/billing";
/**
- * Unit tests for the @shared/billing money/meter helpers the portal billing
+ * Unit tests for the @app/billing money/meter helpers the portal billing
* surface (and the editor cloud surface) depend on. docCapForMoney mirrors the
* backend's cap→PDF conversion and meterState mirrors the BE warn/degrade bands,
* so these invariants matter beyond cosmetics.
diff --git a/frontend/portal/src/billing/stripe.test.ts b/frontend/editor/src/portal/billing/stripe.test.ts
similarity index 98%
rename from frontend/portal/src/billing/stripe.test.ts
rename to frontend/editor/src/portal/billing/stripe.test.ts
index eeb9e16dcd..53a732fb63 100644
--- a/frontend/portal/src/billing/stripe.test.ts
+++ b/frontend/editor/src/portal/billing/stripe.test.ts
@@ -11,7 +11,7 @@ const { getClient, invoke } = vi.hoisted(() => ({
}));
vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
-vi.mock("@shared/auth/supabase/supabaseClient", () => ({
+vi.mock("@app/auth/supabase/supabaseClient", () => ({
getSupabaseClient: () => getClient(),
configureSupabase: vi.fn(),
}));
diff --git a/frontend/portal/src/billing/stripe.ts b/frontend/editor/src/portal/billing/stripe.ts
similarity index 98%
rename from frontend/portal/src/billing/stripe.ts
rename to frontend/editor/src/portal/billing/stripe.ts
index 068046ef37..87fa82cf7b 100644
--- a/frontend/portal/src/billing/stripe.ts
+++ b/frontend/editor/src/portal/billing/stripe.ts
@@ -1,4 +1,4 @@
-import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
+import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
/**
diff --git a/frontend/portal/src/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css
similarity index 100%
rename from frontend/portal/src/components/AppShell.css
rename to frontend/editor/src/portal/components/AppShell.css
diff --git a/frontend/portal/src/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx
similarity index 100%
rename from frontend/portal/src/components/AppShell.tsx
rename to frontend/editor/src/portal/components/AppShell.tsx
diff --git a/frontend/portal/src/components/AssistantButton.css b/frontend/editor/src/portal/components/AssistantButton.css
similarity index 100%
rename from frontend/portal/src/components/AssistantButton.css
rename to frontend/editor/src/portal/components/AssistantButton.css
diff --git a/frontend/portal/src/components/AssistantButton.tsx b/frontend/editor/src/portal/components/AssistantButton.tsx
similarity index 87%
rename from frontend/portal/src/components/AssistantButton.tsx
rename to frontend/editor/src/portal/components/AssistantButton.tsx
index e88130094b..7f3586c8cc 100644
--- a/frontend/portal/src/components/AssistantButton.tsx
+++ b/frontend/editor/src/portal/components/AssistantButton.tsx
@@ -11,9 +11,11 @@ export function AssistantButton() {
type="button"
className={"portal-assistant-btn" + (assistantOpen ? " is-active" : "")}
onClick={toggleAssistant}
- aria-label={assistantOpen ? t("assistant.close") : t("assistant.open")}
+ aria-label={
+ assistantOpen ? t("portal.assistant.close") : t("portal.assistant.open")
+ }
aria-expanded={assistantOpen}
- title={t("assistant.title")}
+ title={t("portal.assistant.title")}
>
{assistantOpen ? (