Compare commits

..
3420 changed files with 45758 additions and 97210 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`.
- Read the PR description / commit messages for stated intent. Do **not** invent
history or motivation that isn't evidenced (state current behavior in present tense).
- Classify touched files by layer:
- **Frontend**: tools (`frontend/editor/src/core/components/tools/*` or `.../core/tools/*`),
- **Frontend**: tools (`frontend/src/editor/core/components/tools/*` or `.../core/tools/*`),
hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n
(`public/locales/en-US`).
- **Java backend**: controllers (`.../controller/api/...`), services, models, config.
+5 -5
View File
@@ -53,11 +53,11 @@ gh pr diff <pr> --name-only # or: git diff --name-only <base>...HEAD
Map changed frontend files to URLs generically:
- **Tools**: a changed `components/tools/<toolDir>/…` or `hooks/tools/<tool>/…`
toolId → URL via the repo's own rule `getToolUrlPath` in
[toolsTaxonomy.ts:200](frontend/editor/src/core/data/toolsTaxonomy.ts): `/` + the
[toolsTaxonomy.ts:200](frontend/src/editor/core/data/toolsTaxonomy.ts): `/` + the
id kebab-cased (`addPageNumbers``/add-page-numbers`).
- **Pages/routes**: changed `filesPage/*``/files`, etc.
- `--all`: enumerate every tool in the registry instead of just changed ones.
Write `frontend/editor/screenshots/ui-diff/targets.json` =
Write `frontend/screenshots/ui-diff/targets.json` =
`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes
it generic - the spec never names a tool.
@@ -69,12 +69,12 @@ the full viewport - or the `--scope` container if given). Ensure the harness is
(node_modules + icons).
```
# after = current head
cd frontend/editor && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
cd frontend && PR_SHOT_SIDE=after PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# before = base, in an isolated worktree (copy the spec + targets.json in)
git worktree add ../ba-base origin/<baseRefName> # or the merge-base
# set up its frontend, copy spec + screenshots/ui-diff/targets.json across, then:
cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
cd ../ba-base/frontend && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
npx playwright test --project=stubbed ui-before-after.spec.ts
# copy its screenshots/ui-diff/before/ back next to after/. Repeat with
# PR_SHOT_THEME=dark if --theme includes dark. Remove worktree when done.
@@ -82,7 +82,7 @@ cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \
### 4. Auto-diff (surface what changed)
```
cd frontend/editor && node <skill>/diff-shots.mjs \
cd frontend && node <skill>/diff-shots.mjs \
screenshots/ui-diff/before screenshots/ui-diff/after screenshots/ui-diff
```
Produces `diff-report.json` classifying each view `unchanged | changed | added |
@@ -2,7 +2,7 @@
// unchanged | changed | added | removed, and CROP each changed pair to the
// affected region (bounding box of differing pixels + padding) - unless the
// change spans most of the page, in which case the full frame is kept.
// Run from frontend/editor (so deps resolve):
// Run from frontend (so deps resolve):
// node <skill>/diff-shots.mjs <beforeDir> <afterDir> [outDir]
// Env:
// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001)
@@ -1,5 +1,5 @@
// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image).
// Run from frontend/editor (so @playwright/test resolves):
// Run from frontend (so @playwright/test resolves):
// node <skill>/shoot-sections.mjs <montage.html> <outDir>
import path from "node:path";
import { pathToFileURL } from "node:url";
+6 -6
View File
@@ -28,13 +28,13 @@ current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
## What this repo gives you (use it, don't reinvent)
- **Stubbed Playwright project** = backend-free screenshots via `page.route()` mocks.
Reference implementation: `frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts`.
Reference implementation: `frontend/src/editor/core/tests/stubbed/files-page-screenshots.spec.ts`.
It already shows the light / **dark** / **RTL** passes, JWT seeding, IndexedDB
seeding, and dumping PNGs to a `screenshots/<area>/` folder. Copy its shape.
- Helpers: `frontend/editor/src/core/tests/helpers/ui-helpers.ts`
- Helpers: `frontend/src/editor/core/tests/helpers/ui-helpers.ts`
(`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …)
and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`).
- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`).
- Config: `frontend/playwright.config.ts` (run from `frontend/`).
- Report template: [report-template.html](report-template.html) - self-contained,
one big image at a time, a global light/dark slider that flips every shot,
thumbnail rail, prev/next + arrow keys, and a Findings tab.
@@ -54,13 +54,13 @@ current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both),
Worktrees have no `node_modules` and no generated icons. From repo root:
```
cd frontend && npm ci # or junction main's node_modules (see memory)
cd frontend/editor && node scripts/generate-icons.js
cd frontend && node scripts/generate-icons.js
```
Kill any stale dev server first (it serves old modules):
`Get-NetTCPConnection -LocalPort 5173 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }`
### 3. Write the capture spec
Create `frontend/editor/src/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
Create `frontend/src/editor/core/tests/stubbed/<feature>-walkthrough.spec.ts`,
modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
- stub the APIs it needs, drive the UI to that state, wait on a real locator
(not a fixed sleep), `await settle(page)` for Mantine portals, then
@@ -71,7 +71,7 @@ modeled on `files-page-screenshots.spec.ts`. For each enumerated view:
- Name shots `NN_<view>_<theme>.png` so light/dark pair up by suffix.
- Prefer **stable test-ids** over translated accessible names (RTL/i18n breaks text locators).
Run it: `cd frontend/editor && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
Run it: `cd frontend && npx playwright test --project=stubbed <feature>-walkthrough.spec.ts`.
Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters.
### 4. Build the report
+4 -4
View File
@@ -26,16 +26,16 @@ version_builds/
node_modules/
**/node_modules/
frontend/node_modules/
frontend/editor/dist/
frontend/editor/playwright-report/
frontend/dist/
frontend/playwright-report/
.npm/
.yarn/
# Tauri/desktop builds
src-tauri/target/
src-tauri/dist/
frontend/editor/src-tauri/target/
frontend/editor/src-tauri/dist/
frontend/src-tauri/target/
frontend/src-tauri/dist/
# IDE and editor
.idea/
+13 -23
View File
@@ -1,28 +1,18 @@
# Review ownership is assigned to teams where possible.
# Teams can only contain org members, so outside collaborators are listed by hand.
#
# @Stirling-Tools/maintainers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/backend-reviewers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/frontend-reviewers - Frooodle, jbrunton96, ConnorYoh, reecebrowne, EthanHealy01
# @Stirling-Tools/devops-reviewers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/all - all of the above
#
# Outside collaborators (need Write access to count as owners): @Ludy87 @balazs-szucs
# Default owners for everything
* @Stirling-Tools/maintainers @Ludy87
# All PRs must be approved by Frooodle or Ludy87
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
# Backend
/app/** @Stirling-Tools/backend-reviewers @Ludy87 @balazs-szucs
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs
# V2 frontend
/frontend/** @Stirling-Tools/frontend-reviewers @balazs-szucs
/app/core/src/main/resources/static/** @Stirling-Tools/frontend-reviewers @Ludy87 @balazs-szucs
#V2 frontend
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
# V2 docker
/docker/backend/** @Stirling-Tools/devops-reviewers @Ludy87
/docker/frontend/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87
/docker/compose/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87
#V2 docker
/docker/backend/** @Frooodle @Ludy87 @DarioGii
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
# GHA (all users)
/.github/** @Stirling-Tools/all @Ludy87 @balazs-szucs
#GHA (All users)
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
+10 -10
View File
@@ -92,12 +92,12 @@ frontend: &frontend
# job on changes to any of these.
tauri: &tauri
- *ci
- frontend/editor/src-tauri/**
- frontend/editor/src/desktop/**
- frontend/editor/tsconfig.desktop.vite.json
- frontend/src-tauri/**
- frontend/src/editor/desktop/**
- frontend/tsconfig.desktop.vite.json
- frontend/package.json
- frontend/package-lock.json
- frontend/editor/vite.config.ts
- frontend/vite.config.ts
- .github/workflows/tauri-build.yml
- Taskfile.yml
- .taskfiles/desktop.yml
@@ -121,9 +121,9 @@ engine: &engine
generated-models: &generated-models
- *ci
- *openapi
- frontend/editor/scripts/generate-tool-api-types.mts
- frontend/editor/src/core/types/toolApiTypes.ts
- frontend/editor/src/core/types/toolIO.ts
- frontend/scripts/generate-tool-api-types.mts
- frontend/src/editor/core/types/toolApiTypes.ts
- frontend/src/editor/core/types/toolIO.ts
- engine/scripts/generate_tool_models.py
- engine/src/stirling/models/tool_models.py
- engine/src/stirling/models/tool_io.py
@@ -135,7 +135,7 @@ licenses-frontend: &licenses-frontend
- ".github/workflows/frontend-backend-licenses-update.yml"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/editor/scripts/generate-licenses.js"
- "frontend/scripts/generate-licenses.js"
licenses-backend: &licenses-backend
- ".github/workflows/frontend-backend-licenses-update.yml"
@@ -146,8 +146,8 @@ licenses-backend: &licenses-backend
proprietary: &proprietary
- *ci
- app/proprietary/**
- frontend/editor/src/proprietary/**
- frontend/editor/src/core/tests/enterprise/**
- frontend/src/editor/proprietary/**
- frontend/src/editor/core/tests/enterprise/**
- testing/compose/docker-compose-keycloak-oauth.yml
- testing/compose/docker-compose-keycloak-saml.yml
- testing/compose/keycloak-realm-oauth.json
+1
View File
@@ -11,6 +11,7 @@
"LaserKaspar",
"sbplat",
"reecebrowne",
"DarioGii",
"ConnorYoh",
"EthanHealy01",
"jbrunton96",
+11 -3
View File
@@ -73,6 +73,14 @@ updates:
- "react-dom"
- "@types/react"
- "@types/react-dom"
typescript-eslint:
patterns:
- "@typescript-eslint/*"
- "typescript-eslint"
eslint:
patterns:
- "eslint"
- "@eslint/*"
vite:
patterns:
- "vite"
@@ -120,9 +128,9 @@ updates:
- package-ecosystem: cargo
directories:
- /frontend/editor/src-tauri
- /frontend/editor/src-tauri/thumbnail-handler
- /frontend/editor/src-tauri/provisioner
- /frontend/src-tauri
- /frontend/src-tauri/thumbnail-handler
- /frontend/src-tauri/provisioner
schedule:
interval: "weekly"
cooldown:
+3 -3
View File
@@ -51,7 +51,7 @@ labels:
- label: 'Translation'
files:
- 'frontend/editor/public/locales/[a-zA-Z]{2}-[a-zA-Z\-]{2,7}/translation.toml'
- 'frontend/public/locales/[a-zA-Z]{2}-[a-zA-Z\-]{2,7}/translation.toml'
- 'scripts/ignore_translation.toml'
- 'scripts/remove_translation_keys.sh'
- 'scripts/replace_translation_line.sh'
@@ -70,8 +70,8 @@ labels:
- label: 'Tauri'
files:
- 'frontend/editor/src-tauri/**'
- 'frontend/editor/src-tauri/.*'
- 'frontend/src-tauri/**'
- 'frontend/src-tauri/.*'
- label: 'engine'
files:
+2 -2
View File
@@ -13,7 +13,7 @@ Usage:
"""
# Sample for Windows:
# python .github/scripts/check_language_toml.py --reference-file frontend/editor/public/locales/en-US/translation.toml --branch "" --files frontend/editor/public/locales/de-DE/translation.toml frontend/editor/public/locales/fr-FR/translation.toml
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-US/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
import argparse
import glob
@@ -308,7 +308,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-US/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/editor/public/locales/en-US/translation.toml)"
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-US/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-US/translation.toml)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
+1 -3
View File
@@ -14,9 +14,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
ART_ROOT = Path(sys.argv[1])
CONF = Path(
sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json"
)
CONF = Path(sys.argv[2] if len(sys.argv) > 2 else "frontend/src-tauri/tauri.conf.json")
def load_pubkey():
+2 -2
View File
@@ -86,7 +86,7 @@ jobs:
fi
fi
else
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
if [ "$is_auth" = true ]; then
should=true
@@ -193,7 +193,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -54,6 +54,7 @@ jobs:
github.event.comment.user.login == 'Ludy87' ||
github.event.comment.user.login == 'balazs-szucs' ||
github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'EthanHealy01' ||
github.event.comment.user.login == 'jbrunton96' ||
github.event.comment.user.login == 'ConnorYoh'
@@ -205,7 +206,7 @@ jobs:
token: ${{ steps.setup-bot.outputs.token }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
@@ -241,7 +242,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
+3 -3
View File
@@ -56,7 +56,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
@@ -294,7 +294,7 @@ jobs:
if: always()
working-directory: frontend
run: >
npx tsx editor/scripts/report-flaky-tests.mts
npx tsx scripts/report-flaky-tests.mts
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
@@ -330,7 +330,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: "pip"
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
cache-suffix: generated-models
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
+5 -5
View File
@@ -6,7 +6,7 @@ on:
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- "frontend/editor/public/locales/*/translation.toml"
- "frontend/public/locales/*/translation.toml"
- ".github/scripts/check_language_toml.py"
- ".github/workflows/check_toml.yml"
@@ -76,7 +76,7 @@ jobs:
exit 1
fi
# Get changed files and filter for TOML translation files
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/editor/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
# Check if any files were found
if [ ! -s changed_files.txt ]; then
echo "No TOML translation files changed in this PR"
@@ -166,12 +166,12 @@ jobs:
// Determine reference file
let referenceFilePath;
if (changedFiles.includes("frontend/editor/public/locales/en-US/translation.toml")) {
if (changedFiles.includes("frontend/public/locales/en-US/translation.toml")) {
console.log("Using PR branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner,
repo: prRepoName,
path: "frontend/editor/public/locales/en-US/translation.toml",
path: "frontend/public/locales/en-US/translation.toml",
ref: branch,
});
@@ -183,7 +183,7 @@ jobs:
const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner,
repo: repoName,
path: "frontend/editor/public/locales/en-US/translation.toml",
path: "frontend/public/locales/en-US/translation.toml",
ref: "main",
});
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: 25
distribution: temurin
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
fi
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
+5 -5
View File
@@ -21,7 +21,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
@@ -93,7 +93,7 @@ jobs:
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
run: npx tsx scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Generate JaCoCo report from e2e:live .exec
@@ -230,8 +230,8 @@ jobs:
run: |
echo "::group::Playwright output dirs"
# Playwright anchors its default outputDir + HTML report to the
# nearest package.json, which is frontend/ (frontend/editor has
# none), so artifacts land under frontend/, not frontend/editor/.
# nearest package.json, which is frontend/ (frontend has
# none), so artifacts land under frontend/, not frontend/.
ls -la frontend/playwright-report 2>/dev/null \
|| echo "no playwright-report at frontend/"
ls -la frontend/test-results 2>/dev/null \
@@ -246,7 +246,7 @@ jobs:
# test-results/ holds the per-test trace.zip (with browser console
# logs) + screenshots/video; playwright-report/ is the HTML report.
# Both live under frontend/ (Playwright anchors them to the nearest
# package.json, which is frontend/; frontend/editor has none).
# package.json, which is frontend/; frontend has none).
path: |
frontend/playwright-report/
frontend/test-results/
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
run: npx tsx scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Upload Playwright report
@@ -113,31 +113,31 @@ jobs:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
working-directory: frontend
run: |
mkdir -p editor/src/assets
npx --yes license-report --only=prod --output=json > editor/src/assets/3rdPartyLicenses.json
mkdir -p src/assets
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
- name: Postprocess with project script (BASE version)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
env:
PR_IS_FORK: "true"
run: |
node base/frontend/editor/scripts/generate-licenses.js \
--input frontend/editor/src/assets/3rdPartyLicenses.json
node base/frontend/scripts/generate-licenses.js \
--input frontend/src/assets/3rdPartyLicenses.json
- name: Copy postprocessed artifacts back (fork PRs)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
run: |
mkdir -p frontend/editor/src/assets
if [ -f "base/frontend/editor/src/assets/3rdPartyLicenses.json" ]; then
cp base/frontend/editor/src/assets/3rdPartyLicenses.json frontend/editor/src/assets/3rdPartyLicenses.json
mkdir -p frontend/src/assets
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
fi
if [ -f "base/frontend/editor/src/assets/license-warnings.json" ]; then
cp base/frontend/editor/src/assets/license-warnings.json frontend/editor/src/assets/license-warnings.json
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
fi
- name: Check for license warnings
run: |
if [ -f "frontend/editor/src/assets/license-warnings.json" ]; then
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
else
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
@@ -185,10 +185,10 @@ jobs:
echo ""
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
echo "❌ **Failed** incompatible or unknown licenses found."
if [ -f "frontend/editor/src/assets/license-warnings.json" ]; then
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo ""
echo "### Warnings"
jq -r '.warnings[] | "- \(.message)"' frontend/editor/src/assets/license-warnings.json || true
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
fi
else
echo "✅ **Passed** no license warnings detected."
@@ -214,7 +214,7 @@ jobs:
const fs = require('fs');
let warningDetails = '';
try {
const warnings = JSON.parse(fs.readFileSync('frontend/editor/src/assets/license-warnings.json', 'utf8'));
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
} catch (e) {
warningDetails = 'Unable to read warning details';
@@ -254,7 +254,7 @@ jobs:
- name: Commit changes (Push only)
if: github.event_name == 'push'
run: |
git add frontend/editor/src/assets/3rdPartyLicenses.json
git add frontend/src/assets/3rdPartyLicenses.json
# Note: Do NOT commit license-warnings.json - it's only for PR review
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
@@ -272,7 +272,7 @@ jobs:
The following licenses may require review for corporate compatibility:
$(cat frontend/editor/src/assets/license-warnings.json | jq -r '.warnings[].message')
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
Please review these licenses to ensure they are acceptable for your use case."
fi
@@ -345,7 +345,7 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
+3 -3
View File
@@ -130,19 +130,19 @@ jobs:
run: |
python scripts/coverage-summary.py \
--title "Frontend Vitest coverage" \
--vitest frontend/editor/coverage/coverage-summary.json \
--vitest frontend/coverage/coverage-summary.json \
--github-step-summary
- name: Upload vitest coverage report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-coverage
path: frontend/editor/coverage/
path: frontend/coverage/
retention-days: 7
if-no-files-found: warn
- name: Upload frontend build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-build
path: frontend/editor/dist/
path: frontend/dist/
retention-days: 3
+15 -14
View File
@@ -52,7 +52,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
@@ -142,7 +142,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
@@ -240,7 +240,7 @@ jobs:
# before the second setup-java overwrites JAVA_HOME.
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
@@ -252,7 +252,7 @@ jobs:
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
@@ -441,7 +441,7 @@ jobs:
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
run: |
cat > ./frontend/editor/src-tauri/tauri.windows.conf.json <<EOF
cat > ./frontend/src-tauri/tauri.windows.conf.json <<EOF
{
"bundle": {
"windows": {
@@ -454,7 +454,7 @@ jobs:
}
EOF
echo "Generated tauri.windows.conf.json (alias masked):"
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/src-tauri/tauri.windows.conf.json
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
@@ -475,7 +475,7 @@ jobs:
fi
- name: Build Tauri app
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -502,9 +502,10 @@ jobs:
SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}
CI: true
with:
projectPath: ./frontend/editor
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
updaterJsonKeepUniversal: true
# Bundled libwayland conflicts with the host's on some distros (Fedora
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). Repack without it,
@@ -521,7 +522,7 @@ jobs:
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: |
set -euo pipefail
AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1)
AI=$(find "$PWD/frontend/src-tauri/target" -name "*.AppImage" | head -1)
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
chmod +x "$AI"
WORK=$(mktemp -d)
@@ -578,7 +579,7 @@ jobs:
# inner exe before packing and the setup exe after, so verifying the setup
# exe is the arm64 equivalent of the MSI + inner-exe check below.
if ("${{ matrix.platform }}" -eq "windows-11-arm") {
$setupExes = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*-setup.exe" -Recurse -File
$setupExes = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*-setup.exe" -Recurse -File
if ($setupExes.Count -eq 0) {
Write-Host "[ERROR] No NSIS installer found under target/"
exit 1
@@ -598,7 +599,7 @@ jobs:
$allSigned = $true
# Check MSI installer (outer wrapper - what users download)
$msiFiles = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*.msi" -Recurse -File
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
if ($msiFiles.Count -eq 0) {
Write-Host "[ERROR] No MSI found under target/"
exit 1
@@ -681,7 +682,7 @@ jobs:
# Absolute dist path so the cd below can't break the copy targets.
DIST="$GITHUB_WORKSPACE/dist"
mkdir -p "$DIST"
cd ./frontend/editor/src-tauri/target
cd ./frontend/src-tauri/target
echo "=== tauri bundle artifacts ==="
find . -path "*/bundle/*" \( -name "*.msi" -o -name "*.deb" \
@@ -742,7 +743,7 @@ jobs:
with:
sparse-checkout: |
.github/scripts/verify-updater-signatures.py
frontend/editor/src-tauri/tauri.conf.json
frontend/src-tauri/tauri.conf.json
sparse-checkout-cone-mode: false
- name: Download all Tauri artifacts
@@ -891,7 +892,7 @@ jobs:
run: |
python3 -m pip install --quiet 'cryptography==44.0.0'
python3 .github/scripts/verify-updater-signatures.py \
./artifacts/tauri frontend/editor/src-tauri/tauri.conf.json
./artifacts/tauri frontend/src-tauri/tauri.conf.json
# workflow_dispatch path requires platform=='all' so a single-platform
# dispatch can't overwrite an existing release's full latest.json with a
+4 -4
View File
@@ -50,13 +50,13 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -67,7 +67,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Convert repository owner to lowercase
id: repoowner
@@ -75,7 +75,7 @@ jobs:
- name: Generate tags for base image
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
+7 -7
View File
@@ -60,7 +60,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
@@ -103,20 +103,20 @@ jobs:
cosign-release: "v2.4.1"
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Convert repository owner to lowercase
id: repoowner
@@ -125,7 +125,7 @@ jobs:
- name: Generate tags for latest
id: meta
if: env.RUN_MAIN_APP == 'true'
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -173,7 +173,7 @@ jobs:
- name: Generate tags for latest-fat
id: meta-fat
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
@@ -217,7 +217,7 @@ jobs:
- name: Generate tags for ultra-lite
id: meta-lite
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
+2 -2
View File
@@ -26,13 +26,13 @@ jobs:
uses: imjasonh/setup-crane@31b88afe9de28ae0ffa220711af4b60be9435f6e # v0.4
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
+2 -2
View File
@@ -84,12 +84,12 @@ jobs:
body: |
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot].
Regenerates `frontend/editor/src/portal/generated/docsManifest.json`
Regenerates `frontend/src/processor/proprietary/generated/docsManifest.json`
from the Stirling docs repo via `npm run docs:sync`.
labels: |
Documentation
github-actions
Front End
add-paths: frontend/editor/src/portal/generated/docsManifest.json
add-paths: frontend/src/processor/proprietary/generated/docsManifest.json
delete-branch: true
sign-commits: true
+5 -5
View File
@@ -12,7 +12,7 @@ on:
- "app/proprietary/build.gradle"
- "gradle/spotless.gradle"
- "README.md"
- "frontend/editor/public/locales/*/translation.toml"
- "frontend/public/locales/*/translation.toml"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
@@ -72,7 +72,7 @@ jobs:
- name: Sync translation TOML files
run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-US/translation.toml" --branch main
- name: Sort translation TOML files
run: |
@@ -80,7 +80,7 @@ jobs:
- name: Commit translation files
run: |
git add frontend/editor/public/locales/*/translation.toml
git add frontend/public/locales/*/translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
- name: Sync README.md
@@ -110,7 +110,7 @@ jobs:
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`frontend/editor/public/locales/*/translation.toml`) to reflect changes in the reference file `en-US/translation.toml`.
- Updated translation files (`frontend/public/locales/*/translation.toml`) to reflect changes in the reference file `en-US/translation.toml`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
- **Format**: TOML
@@ -135,5 +135,5 @@ jobs:
sign-commits: true
add-paths: |
README.md
frontend/editor/public/locales/*/translation.toml
frontend/public/locales/*/translation.toml
scripts/ignore_translation.toml
+15 -15
View File
@@ -152,7 +152,7 @@ jobs:
- name: Cache Rust build
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: frontend/editor/src-tauri
workspaces: frontend/src-tauri
# Stable key shared across workflows so the nightly warmer.
# rust-cache still appends OS + rustc + Cargo.lock.
shared-key: tauri-${{ matrix.name }}
@@ -162,7 +162,7 @@ jobs:
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
@@ -174,7 +174,7 @@ jobs:
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
@@ -353,7 +353,7 @@ jobs:
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
run: |
cat > ./frontend/editor/src-tauri/tauri.windows.conf.json <<EOF
cat > ./frontend/src-tauri/tauri.windows.conf.json <<EOF
{
"bundle": {
"windows": {
@@ -386,7 +386,7 @@ jobs:
- name: Build Tauri app (signed)
if: env.SIGN_BUNDLE == 'true'
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -412,7 +412,7 @@ jobs:
SM_CODE_SIGNING_CERT_SHA1_HASH: ${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}
CI: true
with:
projectPath: ./frontend/editor
projectPath: ./frontend
tauriScript: npx tauri
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
@@ -421,7 +421,7 @@ jobs:
- name: Build Tauri app (unsigned)
if: env.SIGN_BUNDLE != 'true'
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: "0"
@@ -432,7 +432,7 @@ jobs:
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
CI: true
with:
projectPath: ./frontend/editor
projectPath: ./frontend
tauriScript: npx tauri
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
@@ -447,7 +447,7 @@ jobs:
- name: Build Tauri app (Linux AppImage)
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
continue-on-error: true
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
@@ -460,7 +460,7 @@ jobs:
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
CI: true
with:
projectPath: ./frontend/editor
projectPath: ./frontend
tauriScript: npx tauri
args: --bundles appimage
@@ -472,7 +472,7 @@ jobs:
continue-on-error: true
run: |
set -euo pipefail
AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1)
AI=$(find "$PWD/frontend/src-tauri/target" -name "*.AppImage" | head -1)
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
chmod +x "$AI"
WORK=$(mktemp -d)
@@ -509,7 +509,7 @@ jobs:
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/editor/src-tauri/target
cd ./frontend/src-tauri/target
DMG_FILE=$(find . -name "*.dmg" | head -1)
if [ -n "$DMG_FILE" ]; then
echo "Found DMG: $DMG_FILE"
@@ -526,7 +526,7 @@ jobs:
# Absolute dist path so the cd below can't break the copy targets.
DIST="$GITHUB_WORKSPACE/dist"
mkdir -p "$DIST"
cd ./frontend/editor/src-tauri/target
cd ./frontend/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
@@ -638,7 +638,7 @@ jobs:
- name: Verify build artifacts
shell: bash
run: |
cd ./frontend/editor/src-tauri/target
cd ./frontend/src-tauri/target
# Check for expected artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ] || [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
@@ -669,7 +669,7 @@ jobs:
- name: Test artifact sizes
shell: bash
run: |
cd ./frontend/editor/src-tauri/target
cd ./frontend/src-tauri/target
echo "Artifact sizes for ${{ matrix.name }}:"
find . -name "*.exe" -o -name "*.dmg" -o -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" -o -name "*.msi" | while read file; do
if [ -f "$file" ]; then
+4 -4
View File
@@ -61,7 +61,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -79,7 +79,7 @@ jobs:
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
@@ -107,7 +107,7 @@ jobs:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
id: buildx
@@ -199,7 +199,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
id: buildx
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
@@ -67,7 +67,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
+5 -5
View File
@@ -24,7 +24,7 @@ configs/
watchedFolders/
# The rule above targets the app's runtime watched-folders working dir, but it
# also matches this frontend source component dir; keep the source tracked.
!frontend/editor/src/proprietary/components/watchedFolders/
!frontend/src/editor/proprietary/components/watchedFolders/
clientWebUI/
# Scratch dir used by local fixture-regeneration runs (see
# app/proprietary/src/test/resources/db-migration-fixtures/README.md).
@@ -174,7 +174,7 @@ app/proprietary/build
common/build
proprietary/build
stirling-pdf/build
frontend/editor/src-tauri/provisioner/target
frontend/src-tauri/provisioner/target
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -225,7 +225,7 @@ out/
*.asc
# Allow test fixture certificates (synthetic, no real credentials)
!frontend/editor/src/core/tests/test-fixtures/certs/**
!frontend/src/editor/core/tests/test-fixtures/certs/**
# SSH Keys
*.pub
@@ -267,7 +267,7 @@ node_modules/
*compact*.json
test_batch.json
*.backup.*.json
frontend/editor/public/locales/*/translation.backup*.json
frontend/public/locales/*/translation.backup*.json
# Development/build artifacts
.gradle-cache/
@@ -292,4 +292,4 @@ docs/type3/signatures/
*.playwright-mcp.png
# Local screenshot artifacts from *-screenshots.spec.ts
frontend/editor/screenshots/
frontend/screenshots/
+2 -2
View File
@@ -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/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
frontend/src/editor/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/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
frontend/src/processor/proprietary/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.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"ignoredFiles": [
"frontend/editor/src-tauri/icons/icon.png"
"frontend/src-tauri/icons/icon.png"
]
}
+6 -16
View File
@@ -39,7 +39,6 @@ tasks:
provisioner:
desc: "Build installer provisioner"
platforms: [windows]
dir: editor
cmds:
- node scripts/build-provisioner.mjs
@@ -47,55 +46,48 @@ tasks:
desc: "Start Tauri desktop dev mode"
deps: [prepare]
ignore_error: true
dir: editor
cmds:
- npx tauri dev --no-watch
build:
desc: "Build Tauri desktop app (production)"
deps: [prepare]
dir: editor
cmds:
- npx tauri build
build:dev:
desc: "Build Tauri desktop app (dev, no bundling)"
deps: [prepare]
dir: editor
cmds:
- npx tauri build --no-bundle
build:dev:mac:
desc: "Build Tauri desktop .app bundle (macOS)"
deps: [prepare]
dir: editor
cmds:
- npx tauri build --bundles app --config '{"bundle":{"createUpdaterArtifacts":false}}'
build:dev:windows:
desc: "Build Tauri desktop NSIS installer (Windows)"
deps: [prepare]
dir: editor
cmds:
- npx tauri build --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":false}}'
build:dev:linux:
desc: "Build Tauri desktop AppImage (Linux)"
deps: [prepare]
dir: editor
cmds:
- npx tauri build --bundles appimage --config '{"bundle":{"createUpdaterArtifacts":false}}'
test:
desc: "Run Tauri/Cargo tests"
deps: [prepare]
dir: editor/src-tauri
dir: src-tauri
cmds:
- cargo test
clean:
desc: "Clean Tauri/Cargo build artifacts"
dir: editor
cmds:
- task: jlink:clean
- cd src-tauri && cargo clean
@@ -117,7 +109,6 @@ tasks:
jlink:verify:
desc: "Fail the build if the bundled JRE is older than the app JAR requires"
dir: editor
env:
REQUIRED_JAVA: "{{.REQUIRED_JAVA}}"
cmds:
@@ -135,15 +126,15 @@ tasks:
platforms: [windows]
- cmd: ./gradlew bootJar --no-daemon -PjpdfiumPlatforms={{.JPDFIUM_PLATFORMS}}
platforms: [linux, darwin]
- mkdir -p frontend/editor/src-tauri/libs
- cp app/core/build/libs/stirling-pdf-*.jar frontend/editor/src-tauri/libs/
- mkdir -p frontend/src-tauri/libs
- cp app/core/build/libs/stirling-pdf-*.jar frontend/src-tauri/libs/
status:
- test -f frontend/editor/src-tauri/libs/stirling-pdf-*.jar
- test -f frontend/src-tauri/libs/stirling-pdf-*.jar
jlink:runtime:
desc: "Create custom JRE with jlink"
deps: [jlink:jar]
dir: editor/src-tauri
dir: src-tauri
cmds:
- rm -rf runtime/jre
- mkdir -p runtime
@@ -202,7 +193,7 @@ tasks:
jlink:clean:
desc: "Remove JLink runtime and bundled JARs"
dir: editor/src-tauri
dir: src-tauri
cmds:
- rm -rf libs runtime
@@ -215,7 +206,6 @@ tasks:
desc: "Create universal (arm64+x86_64) JRE for the macOS Tauri build"
deps: [jlink:jar]
platforms: [darwin]
dir: editor
env:
JLINK_MODULES: "{{.JLINK_MODULES}}"
OUTPUT_DIR: src-tauri/runtime/jre
+5 -5
View File
@@ -3,14 +3,14 @@ version: '3'
tasks:
install:
desc: "Install Playwright browsers"
dir: frontend/editor
dir: frontend
deps: [ ':frontend:install' ]
cmds:
- npx playwright install {{.CLI_ARGS}} --with-deps
stubbed:
desc: "Run stubbed E2E tests"
dir: frontend/editor
dir: frontend
deps: [ ':frontend:prepare' ]
cmds:
- npx playwright test --project=stubbed {{.CLI_ARGS}}
@@ -82,7 +82,7 @@ tasks:
live:runner:
internal: true
deps: [ ':frontend:prepare' ]
dir: frontend/editor
dir: frontend
vars:
BASE_DIR: '{{.ROOT_DIR}}/.test-state/playwright'
cmds:
@@ -151,14 +151,14 @@ tasks:
up first with:
task e2e:oauth:up
task e2e:saml:up
dir: frontend/editor
dir: frontend
deps: [ ':frontend:prepare' ]
cmds:
- npx playwright test --project=enterprise {{.CLI_ARGS}}
cross-browser:
desc: "Run stubbed E2E tests on Chromium, Firefox, and WebKit"
dir: frontend/editor
dir: frontend
deps: [ ':frontend:prepare' ]
cmds:
- npx playwright test --project=stubbed --project=stubbed-firefox --project=stubbed-webkit {{.CLI_ARGS}}
+62 -53
View File
@@ -3,7 +3,7 @@ version: '3'
# Tasks operate from the workspace root (frontend/). Editor commands pass
# `editor` as the vite project root (positional after `build` / before the
# mode flag) or use `--project editor/...` for tsc — so the editor lives
# under frontend/editor/ without each task needing a cd.
# under frontend/ without each task needing a cd.
tasks:
install:
@@ -26,34 +26,34 @@ tasks:
vars:
MODE: '{{.MODE | default ""}}'
cmds:
- npx tsx editor/scripts/setup-env.mts{{if .MODE}} --{{.MODE}}{{end}}
- npx tsx scripts/setup-env.mts{{if .MODE}} --{{.MODE}}{{end}}
sources:
- editor/scripts/setup-env.mts
- scripts/setup-env.mts
generates:
- editor/.env.local
- editor/.env{{if .MODE}}.{{.MODE}}{{end}}.local
- .env.local
- .env{{if .MODE}}.{{.MODE}}{{end}}.local
prepare:icons:
internal: true
run: once
deps: [install]
cmds:
- node editor/scripts/generate-icons.js
- node scripts/generate-icons.js
prepare:og:
internal: true
run: when_changed
desc: "Regenerate OG/social-preview metadata from the tool registry"
cmds:
- node editor/scripts/generate-og-metadata.mjs
- node scripts/generate-og-metadata.mjs
sources:
- editor/src/core/types/toolId.ts
- editor/src/core/utils/urlMapping.ts
- editor/src/core/data/useTranslatedToolRegistry.tsx
- editor/public/og_images/*.png
- src/editor/core/types/toolId.ts
- src/editor/core/utils/urlMapping.ts
- src/editor/core/data/useTranslatedToolRegistry.tsx
- public/og_images/*.png
generates:
- editor/src/core/data/ogImageMap.json
- editor/public/og-metadata.json
- src/editor/core/data/ogImageMap.json
- public/og-metadata.json
prepare:
desc: "Set up dev environment"
@@ -88,7 +88,7 @@ tasks:
sh: >-
{{if eq OS "windows"}}powershell -NoProfile -Command '$root = git rev-parse --show-toplevel 2>$null; if (-not $root) { $root = (Get-Location).Path }; Split-Path -Leaf $root'{{else}}basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"{{end}}
cmds:
- npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
- npx vite --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}}
dev:
desc: "Start frontend dev server"
@@ -143,13 +143,13 @@ tasks:
desc: "Production build (default mode)"
deps: [prepare]
cmds:
- npx vite build editor
- npx vite build
build:core:
desc: "Build for core mode"
deps: [prepare]
cmds:
- npx vite build editor --mode core
- npx vite build --mode core
build:proprietary:
desc: "Build for proprietary mode"
@@ -157,7 +157,7 @@ tasks:
vars:
PREVIEW: '{{.PREVIEW | default ""}}'
cmds:
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build --mode proprietary'
build:saas:
desc: "Build for SaaS mode"
@@ -165,7 +165,7 @@ tasks:
- task: prepare
vars: { MODE: saas }
cmds:
- npx vite build editor --mode saas
- npx vite build --mode saas
build:desktop:
desc: "Build for desktop mode"
@@ -173,13 +173,13 @@ tasks:
- task: prepare
vars: { MODE: desktop }
cmds:
- npx vite build editor --mode desktop
- npx vite build --mode desktop
build:prototypes:
desc: "Build for prototypes mode"
deps: [prepare]
cmds:
- npx vite build editor --mode prototypes
- npx vite build --mode prototypes
storybook:
@@ -260,7 +260,8 @@ tasks:
desc: "Run linting"
deps: [install]
cmds:
- task: lint:oxlint
- task: lint:eslint
- task: lint:dpdm
- task: lint:colors
- task: lint:css
@@ -271,35 +272,43 @@ tasks:
# Covers the whole editor tree, including the portal/processor layer and
# public/css. Vendored CSS and build output are excluded via ignoreFiles
# in stylelint.config.mjs.
- npx stylelint "editor/**/*.css"
- npx stylelint "src/**/*.css"
lint:colors:
desc: "Enforce theme tokens — no hardcoded colours or raw primitives in components"
aliases: [lint:colours]
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs
- node editor/scripts/lint/theme-lint.mjs css-colors
- node editor/scripts/lint/theme-lint.mjs code-colors
- node editor/scripts/lint/theme-lint.mjs no-primitives
- node scripts/lint/theme-lint.mjs
- node scripts/lint/theme-lint.mjs css-colors
- node scripts/lint/theme-lint.mjs code-colors
- node scripts/lint/theme-lint.mjs no-primitives
contrast:
desc: "Report low-contrast theme token pairs (warning only, never blocks)"
deps: [install]
cmds:
- node editor/scripts/lint/theme-lint.mjs contrast
- node scripts/lint/theme-lint.mjs contrast
lint:oxlint:
desc: "Run oxlint linting"
lint:eslint:
desc: "Run ESLint linting"
deps: [install]
cmds:
- npx oxlint --config oxlint.config.ts --max-warnings=0
- npx eslint --max-warnings=0
lint:dpdm:
desc: "Run circular import linting"
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
lint:fix:
desc: "Auto-fix lint issues"
deps: [install]
cmds:
- npx oxlint --config oxlint.config.ts --fix
- npx eslint --fix
format:
desc: "Auto-fix code formatting"
@@ -334,14 +343,14 @@ tasks:
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/core/tsconfig.json }
vars: { PROJECT: src/editor/core/tsconfig.json }
typecheck:proprietary:
desc: "Typecheck proprietary build variant"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
vars: { PROJECT: src/editor/proprietary/tsconfig.json }
typecheck:saas:
desc: "Typecheck SaaS build variant"
@@ -350,7 +359,7 @@ tasks:
vars: { MODE: saas }
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/saas/tsconfig.json }
vars: { PROJECT: src/editor/saas/tsconfig.json }
typecheck:desktop:
desc: "Typecheck desktop build variant"
@@ -359,35 +368,35 @@ tasks:
vars: { MODE: desktop }
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/desktop/tsconfig.json }
vars: { PROJECT: src/editor/desktop/tsconfig.json }
typecheck:cloud:
desc: "Typecheck cloud shared layer (standalone)"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/cloud/tsconfig.json }
vars: { PROJECT: src/editor/cloud/tsconfig.json }
typecheck:scripts:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
vars: { PROJECT: scripts/tsconfig.json }
typecheck:prototypes:
desc: "Typecheck prototypes build variant"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/prototypes/tsconfig.json }
vars: { PROJECT: src/editor/prototypes/tsconfig.json }
typecheck:portal:
desc: "Typecheck developer portal build variant"
deps: [install]
cmds:
- task: typecheck:_run
vars: { PROJECT: editor/src/portal/tsconfig.json }
vars: { PROJECT: src/processor/proprietary/tsconfig.json }
typecheck:storybook:
desc: "Typecheck Storybook config and stories"
@@ -424,7 +433,7 @@ tasks:
og:check:
desc: "Fail if committed OG/social-preview metadata is out of date"
cmds:
- node editor/scripts/generate-og-metadata.mjs --check
- node scripts/generate-og-metadata.mjs --check
check:all:
desc: "Full CI quality gate"
@@ -452,13 +461,13 @@ tasks:
desc: "Run editor tests"
deps: [prepare]
cmds:
- npx vitest run --root editor
- npx vitest run --root .
test:watch:
desc: "Run tests in watch mode"
deps: [prepare]
cmds:
- npx vitest --watch --root editor
- npx vitest --watch --root .
test:coverage:
desc: "Run tests with coverage (one-shot; CI-friendly)."
@@ -469,12 +478,12 @@ tasks:
# coverage-summary.py helper consumes; html/text are kept for humans.
#
# reportsDirectory is pinned to ./coverage relative to vitest's root
# (--root editor), so output lands at frontend/editor/coverage/. The
# (--root .), so output lands at frontend/coverage/. The
# CI upload step reads from that path. An earlier attempt with
# `./editor/coverage` double-nested into frontend/editor/editor/coverage;
# `./editor/coverage` double-nested into frontend/coverage;
# pinning future-proofs against vitest changing the default.
- >
npx vitest run --root editor --coverage
npx vitest run --root . --coverage
--coverage.provider=v8
--coverage.reporter=text-summary
--coverage.reporter=json-summary
@@ -489,25 +498,25 @@ tasks:
desc: "Generate tool API types from the Java OpenAPI spec"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts
- npx tsx scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output src/editor/core/types/toolApiTypes.ts --io-output src/editor/core/types/toolIO.ts
sources:
- editor/scripts/generate-tool-api-types.mts
- scripts/generate-tool-api-types.mts
- ../SwaggerDoc.json
generates:
- editor/src/core/types/toolApiTypes.ts
- editor/src/core/types/toolIO.ts
- src/editor/core/types/toolApiTypes.ts
- src/editor/core/types/toolIO.ts
tool-models:check:
desc: "Fail if committed tool API types are out of date"
deps: [install, ":backend:swagger"]
cmds:
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check
- npx tsx scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output src/editor/core/types/toolApiTypes.ts --io-output src/editor/core/types/toolIO.ts --check
licenses:generate:
desc: "Generate frontend license report"
deps: [install]
cmds:
- node editor/scripts/generate-licenses.js
- node scripts/generate-licenses.js
# ============================================================
# Clean
@@ -516,7 +525,7 @@ tasks:
clean:
desc: "Clean build artifacts and caches"
cmds:
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, dist, dist
platforms: [windows]
- cmd: rm -rf node_modules/.vite editor/dist dist
- cmd: rm -rf node_modules/.vite dist dist
platforms: [linux, darwin]
+2 -2
View File
@@ -21,7 +21,7 @@ vars:
':(exclude).devcontainer/*'
':(exclude)app/core/src/main/resources/*'
':(exclude)app/proprietary/src/main/resources/*'
':(exclude)frontend/editor/public/vendor/*'
':(exclude)frontend/public/vendor/*'
':(exclude)*Dockerfile*'
':(exclude)*pdfjs*'
':(exclude)*thirdParty*'
@@ -39,7 +39,7 @@ vars:
':(exclude)*.min.*'
':(exclude)*diff.js'
':(exclude).github/workflows/*'
LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml'
LOCALE_TOML: 'frontend/public/locales/*/translation.toml'
# gitleaks is pinned + checksum-verified by scripts/pre-commit/install_gitleaks.py,
# which owns the version and caches the binary here.
+1 -1
View File
@@ -19,6 +19,6 @@
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
"oxc.oxc-vscode", // Oxc (oxlint) extension for JavaScript/TypeScript linting
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
]
}
+6 -6
View File
@@ -10,14 +10,14 @@ When adding tools, follow this systematic approach using the established pattern
Create these files in the correct directories:
```
frontend/editor/src/hooks/tools/[toolName]/
frontend/src/hooks/tools/[toolName]/
├── use[ToolName]Parameters.ts # Parameter definitions and validation
└── use[ToolName]Operation.ts # Tool operation logic using useToolOperation
frontend/editor/src/components/tools/[toolName]/
frontend/src/components/tools/[toolName]/
└── [ToolName]Settings.tsx # Settings UI component (if needed)
frontend/editor/src/tools/
frontend/src/tools/
└── [ToolName].tsx # Main tool component
```
@@ -128,7 +128,7 @@ export default [ToolName] as ToolComponent;
## 3. Register Tool in System
Update these files to register your new tool:
**Tool Registry** (`frontend/editor/src/data/useTranslatedToolRegistry.tsx`):
**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`):
1. Add imports at the top:
```typescript
import [ToolName] from "../tools/[ToolName]";
@@ -155,7 +155,7 @@ import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Setting
## 4. Add Tooltips (Optional but Recommended)
Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:**
**Tooltip Hook** (`frontend/editor/src/components/tooltips/use[ToolName]Tips.ts`):
**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
@@ -202,7 +202,7 @@ const [ToolName] = (props: BaseToolProps) => {
## 5. Add Translations
Update translation files. **Important: Only update `en-US` files** - other languages are handled separately.
**File to update:** `frontend/editor/public/locales/en-US/translation.toml`
**File to update:** `frontend/public/locales/en-US/translation.toml`
**Required Translation Keys**:
```toml
+18 -18
View File
@@ -139,10 +139,10 @@ 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 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)
- `frontend/.env` — core and shared vars (base, loaded in every mode)
- `frontend/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
- `frontend/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
- `frontend/.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
- Local overrides (API keys, machine-specific settings) go in uncommitted sibling `.env.local` / `.env.saas.local` / `.env.desktop.local` files — Vite automatically layers them on top
- Never use `|| 'hardcoded-fallback'` inline — put defaults in the committed env files
@@ -153,9 +153,9 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
For a broader explanation of the frontend layering and override architecture, read @frontend/DeveloperGuide.md
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/editor/src/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
Before touching colours or theming (tokens, dark mode, accent colours), read @frontend/src/editor/core/theme/README.md — it explains the palette/`--c-*` token system and the rule that literal colours live only in `primitives.css`.
```typescript
// ✅ CORRECT - Use @app/* for all imports
@@ -192,7 +192,7 @@ What goes where:
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (all enforced by the linter). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
@@ -270,7 +270,7 @@ Frontend designed for **stateful document processing**:
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
#### FileContext - Central State Management
**Location**: `frontend/editor/src/core/contexts/FileContext.tsx`
**Location**: `frontend/src/editor/core/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
@@ -295,7 +295,7 @@ Without cleanup: browser crashes with memory leaks.
**Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- **useToolOperation** (`frontend/src/editor/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
@@ -383,7 +383,7 @@ return useToolOperation({
### Frontend Directory Structure
The frontend is organized with a clear separation of concerns:
- **`frontend/editor/src/core/`**: Main application code (shared, production-ready components)
- **`frontend/src/editor/core/`**: Main application code (shared, production-ready components)
- **`core/components/`**: React components organized by feature
- `core/components/tools/`: Individual PDF tool implementations
- `core/components/viewer/`: PDF viewer components
@@ -401,17 +401,17 @@ The frontend is organized with a clear separation of concerns:
- **`core/data/`**: Static data (tool taxonomy, etc.)
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
- **`frontend/editor/src/desktop/`**: Desktop-specific (Tauri) code
- **`frontend/editor/src/proprietary/`**: Proprietary/licensed features
- **`frontend/editor/src-tauri/`**: Tauri (Rust) native desktop application code
- **`frontend/editor/public/`**: Static assets served directly
- **`frontend/src/editor/desktop/`**: Desktop-specific (Tauri) code
- **`frontend/src/editor/proprietary/`**: Proprietary/licensed features
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
- **`frontend/public/`**: Static assets served directly
- `public/locales/`: Translation JSON files
### Component Architecture
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/editor/public/` (modern)
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
- **Internationalization**:
- Backend: `messages_*.properties` files
- Frontend: JSON files in `frontend/editor/public/locales/` (converted from .properties)
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
- Conversion Script: `scripts/convert_properties_to_json.py`
### Configuration Modes
@@ -436,7 +436,7 @@ The frontend is organized with a clear separation of concerns:
4. **Code Style**: Spotless enforces Google Java Format automatically (`task backend:format`)
5. **Translations**:
- Backend: Use helper scripts in `/scripts` for multi-language updates
- Frontend: Update JSON files in `frontend/editor/public/locales/` or use conversion script
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
6. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
## Frontend Architecture Status
@@ -454,7 +454,7 @@ The frontend is organized with a clear separation of concerns:
## Translation Rules
- **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately
- Translation files are located in `frontend/editor/public/locales/`
- Translation files are located in `frontend/public/locales/`
- After changing any translation file, run `task pre-commit:fix`
## Important Notes
+2 -2
View File
@@ -158,7 +158,7 @@ Stirling-PDF/
│ │ │ └── locales/ # Internationalization files (JSON)
│ │ └── vite.config.ts # Vite configuration
│ ├── package.json # Shared workspace dependencies
│ └── oxlint.config.ts # Shared lint config
│ └── eslint.config.mjs # Shared lint config
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
@@ -458,7 +458,7 @@ For Stirling 2.0, new features are built as React components:
1. **Create the React Component:**
```typescript
// frontend/editor/src/tools/NewTool.tsx
// frontend/src/tools/NewTool.tsx
import { useState } from 'react';
import { Button, FileInput, Container } from '@mantine/core';
+12 -14
View File
@@ -10,20 +10,18 @@ if that directory exists, is licensed under the license defined in "app/propriet
if that directory exists, is licensed under the license defined in "app/saas/LICENSE".
* All content that resides under the "engine/" directory of this repository,
if that directory exists, is licensed under the license defined in "engine/LICENSE".
* All content that resides under the "frontend/editor/src/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/proprietary/LICENSE".
* All content that resides under the "frontend/editor/src/desktop/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
* All content that resides under the "frontend/editor/src/cloud/" directory of this repository,
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/editor/src/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
* All content that resides under the "frontend/editor/src/portal-saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal-saas/LICENSE".
* All content that resides under the "frontend/src/editor/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/editor/proprietary/LICENSE".
* All content that resides under the "frontend/src/editor/desktop/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/editor/desktop/LICENSE".
* All content that resides under the "frontend/src/editor/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/editor/saas/LICENSE".
* All content that resides under the "frontend/src/editor/cloud/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/editor/cloud/LICENSE".
* All content that resides under the "frontend/src/editor/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/editor/prototypes/LICENSE".
* All content that resides under the "frontend/src/processor/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/processor/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+1 -1
View File
@@ -86,7 +86,7 @@ If you're using Tauri's built-in updater feature:
## Configuration Files
### 1. Tauri Configuration (frontend/editor/src-tauri/tauri.conf.json)
### 1. Tauri Configuration (frontend/src-tauri/tauri.conf.json)
The Windows signing configuration is already set up:
+1 -1
View File
@@ -10,7 +10,7 @@ dependencies {
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api "org.apache.commons:commons-lang3:${commonsLang3}"
api 'com.drewnoakes:metadata-extractor:2.21.0' // Image metadata extractor
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
+4 -4
View File
@@ -156,7 +156,7 @@ if (buildWithPortal) {
}
// Workspace root holds package.json and node_modules (shared across editor /
// future portal). Editor-specific paths (src, public, dist, tauri) live one
// level deeper under frontend/editor/.
// level deeper under frontend/.
// Vite mode: -PprototypesMode > -PfrontendMode > enableSaas > disableAdditional > proprietary.
def frontendModeOverride = project.findProperty('frontendMode')?.toString()?.toLowerCase()
@@ -176,11 +176,11 @@ def frontendBuildTask = "frontend:build:${frontendMode}"
// Workspace root holds package.json and node_modules (shared across editor /
// future portal). Editor-specific paths (src, public, dist, tauri) live one
// level deeper under frontend/editor/. When the portal lands as an embedded
// level deeper under frontend/. When the portal lands as an embedded
// app, add a sibling frontendPortalDir / frontendPortalDistDir alongside.
def frontendDir = file('../../frontend')
def frontendEditorDir = file('../../frontend/editor')
def frontendEditorDistDir = file('../../frontend/editor/dist')
def frontendEditorDir = file('../../frontend')
def frontendEditorDistDir = file('../../frontend/dist')
def resourcesStaticDir = file('src/main/resources/static')
def generatedFrontendPaths = [
'assets',
@@ -2,7 +2,7 @@ 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/editor/src/portal). Singleton resource (empty
// The admin portal / processor (frontend/src/processor/proprietary). Singleton resource (empty
// resourceId).
PORTAL,
// A stored S3/MCP/API integration configuration.
@@ -298,44 +298,24 @@ class LiveValkeyIntegrationTest {
ValkeyRateLimitStore store = newRateLimitStore(factoryA);
String key = "boundary-" + java.util.UUID.randomUUID();
long capacity = 5;
// refillGreedy tops the bucket up continuously, one token every window/capacity. A 500ms
// window left the drain loop only 100ms before a 6th token appeared, so a slow Valkey
// round-trip broke the count; 4s spaces refills 800ms apart, clear of any burst.
Duration window = Duration.ofSeconds(4);
long refillIntervalMs = window.toMillis() / capacity;
Duration window = Duration.ofMillis(500);
long drainStart = System.nanoTime();
int firstAllowed = 0;
for (int i = 0; i < 10; i++) {
if (store.tryConsume(key, capacity, window).allowed()) firstAllowed++;
}
long drainMs = (System.nanoTime() - drainStart) / 1_000_000;
// Refill never pauses, so a slow drain earns extra tokens honestly - allow exactly the
// number the elapsed time can have produced and no more.
long earned = drainMs / refillIntervalMs;
assertTrue(
firstAllowed >= capacity && firstAllowed <= capacity + earned,
"initial burst must be capacity ("
+ capacity
+ ") plus at most the "
+ earned
+ " token(s) refilled during a "
+ drainMs
+ "ms drain, got "
+ firstAllowed);
assertEquals(capacity, firstAllowed, "must allow exactly capacity tokens initially");
// A fixed-window limiter would hand back a whole fresh capacity at the boundary; a token
// bucket hands back one token per refill interval.
Thread.sleep(refillIntervalMs + 200);
Thread.sleep(window.toMillis() + 50);
int secondAllowed = 0;
for (int i = 0; i < 10; i++) {
long start = System.nanoTime();
for (int i = 0; i < 20 && (System.nanoTime() - start) < 20_000_000L; i++) {
if (store.tryConsume(key, capacity, window).allowed()) secondAllowed++;
}
assertTrue(
secondAllowed >= 1 && secondAllowed < capacity,
"one refill interval must yield about one token, not a fresh full window of "
+ capacity
+ "; got "
secondAllowed <= capacity,
"token-bucket must not let a fresh full capacity be consumed instantly across"
+ " the boundary; got "
+ secondAllowed);
}
+3 -3
View File
@@ -110,9 +110,9 @@ allprojects {
}
def appVersionStr = project.version.toString()
def tauriConfigPath = layout.projectDirectory.file('frontend/editor/src-tauri/tauri.conf.json').asFile.path
def sim1Path = layout.projectDirectory.file('frontend/editor/src/core/testing/serverExperienceSimulations.ts').asFile.path
def sim2Path = layout.projectDirectory.file('frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts').asFile.path
def tauriConfigPath = layout.projectDirectory.file('frontend/src-tauri/tauri.conf.json').asFile.path
def sim1Path = layout.projectDirectory.file('frontend/src/editor/core/testing/serverExperienceSimulations.ts').asFile.path
def sim2Path = layout.projectDirectory.file('frontend/src/editor/proprietary/testing/serverExperienceSimulations.ts').asFile.path
def aurDesktopPkgbuildPath = layout.projectDirectory.file('.github/aur/stirling-pdf-desktop/PKGBUILD').asFile.path
def aurServerPkgbuildPath = layout.projectDirectory.file('.github/aur/stirling-pdf-server-bin/PKGBUILD').asFile.path
+4 -4
View File
@@ -12,12 +12,12 @@ Fork Stirling-PDF and create a new branch out of `main`.
### Add Language Directory and Translation File
1. Create a new language directory in `frontend/editor/public/locales/`
1. Create a new language directory in `frontend/public/locales/`
- Use hyphenated format: `pl-PL` (not underscore)
2. Copy the reference translation file:
- Source: `frontend/editor/public/locales/en-US/translation.toml`
- Destination: `frontend/editor/public/locales/pl-PL/translation.toml`
- Source: `frontend/public/locales/en-US/translation.toml`
- Destination: `frontend/public/locales/pl-PL/translation.toml`
3. Translate all entries in the TOML file
- Keep the TOML structure intact
@@ -49,7 +49,7 @@ ignore = [
> [!IMPORTANT]
> If you add any new translation tags, they must first be added to the `en-US/translation.toml` file. This ensures consistency across all language files.
- New translation tags **must be added** to `frontend/editor/public/locales/en-US/translation.toml` to maintain a reference for other languages.
- New translation tags **must be added** to `frontend/public/locales/en-US/translation.toml` to maintain a reference for other languages.
- After adding the new tags to `en-US/translation.toml`, add and translate them in the respective language file (e.g., `pl-PL/translation.toml`).
- Use the scripts in `scripts/translations/` to validate and manage translations (see `scripts/translations/README.md`)
+3 -3
View File
@@ -894,9 +894,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
"funding": [
{
+3 -3
View File
@@ -12,7 +12,7 @@ RUN npm ci
COPY frontend .
# Generate material-symbols icon subset (normally done by task prepare:icons).
RUN node editor/scripts/generate-icons.js
RUN node scripts/generate-icons.js
# Defaults match prior behaviour. Supabase values are client-publishable build args.
ARG STIRLING_FLAVOR=proprietary
@@ -21,7 +21,7 @@ ARG VITE_SUPABASE_URL=""
# pragma: allowlist secret
ARG VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=""
# Build vite from editor/, output lands in editor/dist/.
# Build vite from editor/, output lands in dist/.
RUN set -eu; \
export STIRLING_FLAVOR="${STIRLING_FLAVOR}"; \
if [ -n "${VITE_SUPABASE_URL}" ]; then export VITE_SUPABASE_URL="${VITE_SUPABASE_URL}"; fi; \
@@ -31,7 +31,7 @@ RUN set -eu; \
# Stage 2: nginx
FROM nginx:alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55
COPY --from=build /app/editor/dist /usr/share/nginx/html
COPY --from=build /app/dist /usr/share/nginx/html
COPY docker/frontend/nginx.conf /etc/nginx/nginx.conf
COPY docker/frontend/entrypoint.sh /entrypoint.sh
+4 -4
View File
@@ -15,7 +15,7 @@
/storybook-static
/editor/build
/editor/dist
/dist
# misc
.DS_Store
@@ -40,12 +40,12 @@ playwright-report
test-results
# auto-generated files
/editor/src/assets/material-symbols-icons.json
/editor/src/assets/material-symbols-icons.d.ts
/src/assets/material-symbols-icons.json
/src/assets/material-symbols-icons.d.ts
# dev update testing - keys, built bundles, screenshots, and generated config override
/scripts/dev-update-test/.keys/
/scripts/dev-update-test/.update-dist/
/scripts/dev-update-test/screenshots/
/editor/src-tauri/tauri.conf.dev-update.json
/src-tauri/tauri.conf.dev-update.json
.a11y-scan/
+15 -15
View File
@@ -1,30 +1,30 @@
dist/
editor/dist/
dist/
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier).
# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each
# have their own Cargo workspace under src-tauri/.
editor/src-tauri/**/target/
editor/src-tauri/gen/
src-tauri/**/target/
src-tauri/gen/
node_modules/
editor/public/vendor/
public/vendor/
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
editor/public/mockServiceWorker.js
public/mockServiceWorker.js
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
editor/public/og-metadata.json
editor/public/og-metadata.saas.json
editor/src/core/data/ogImageMap.json
public/og-metadata.json
public/og-metadata.saas.json
src/editor/core/data/ogImageMap.json
# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim.
editor/src/portal/generated/docsManifest.json
editor/public/pdfjs*/
editor/public/js/thirdParty/
editor/public/css/cookieconsent.css
src/processor/proprietary/generated/docsManifest.json
public/pdfjs*/
public/js/thirdParty/
public/css/cookieconsent.css
# Build / test artifacts that may exist locally even though they're gitignored
storybook-static/
playwright-report/
editor/playwright-report/
playwright-report/
test-results/
test-results/
editor/test-results/
*.min.*
*.md
*.wxs
editor/src/output.css
src/output.css
File diff suppressed because it is too large Load Diff
+3 -8
View File
@@ -59,19 +59,14 @@ if (args.length > 0) {
// checked before it is added to the index.
files = [
...new Set([
...git(
"ls-files",
"--",
"editor/src/**/*.stories.ts",
"editor/src/**/*.stories.tsx",
),
...git("ls-files", "--", "src/**/*.stories.ts", "src/**/*.stories.tsx"),
...git(
"ls-files",
"--others",
"--exclude-standard",
"--",
"editor/src/**/*.stories.ts",
"editor/src/**/*.stories.tsx",
"src/**/*.stories.ts",
"src/**/*.stories.tsx",
),
]),
].sort();
+25 -61
View File
@@ -1,4 +1,3 @@
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
import type { StorybookConfig } from "@storybook/react-vite";
import tsconfigPaths from "vite-tsconfig-paths";
@@ -7,53 +6,14 @@ 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 under editor/src/ (which includes
* the portal layer at editor/src/portal/). MDX docs pages live in
* editor/src/portal/docs/.
* Story files live next to their components under src/editor/ (which includes
* the portal layer at src/processor/proprietary/). MDX docs pages live in
* src/processor/proprietary/docs/.
*/
/** Layer search order for @app/*, mirroring each flavour's vite tsconfig. */
const FLAVOUR_LAYERS: Record<string, string[]> = {
desktop: ["desktop", "cloud", "proprietary", "core"],
saas: ["saas", "cloud", "proprietary", "core"],
cloud: ["cloud", "proprietary", "core"],
prototypes: ["prototypes", "proprietary", "core"],
};
const SUFFIXES = ["", ".tsx", ".ts", "/index.tsx", "/index.ts"];
function flavourAppAlias() {
return {
name: "storybook-app-alias-by-flavour",
// Ahead of vite-tsconfig-paths, which would otherwise answer first with the
// proprietary order.
enforce: "pre" as const,
resolveId(source: string, importer: string | undefined) {
if (!importer || !source.startsWith("@app/")) return null;
const layer = importer
.split("\\")
.join("/")
.match(/\/editor\/src\/(desktop|saas|cloud|prototypes)\//)?.[1];
if (!layer) return null;
const rest = source.slice("@app/".length);
for (const candidate of FLAVOUR_LAYERS[layer]) {
for (const suffix of SUFFIXES) {
const full = resolve(
__dirname,
`../editor/src/${candidate}/${rest}${suffix}`,
);
if (existsSync(full) && statSync(full).isFile()) return full;
}
}
return null;
},
};
}
const config: StorybookConfig = {
stories: [
"../editor/src/portal/**/*.mdx",
"../editor/src/**/*.stories.@(ts|tsx)",
"../src/processor/proprietary/**/*.mdx",
"../src/**/*.stories.@(ts|tsx)",
],
addons: [
"@storybook/addon-themes",
@@ -69,41 +29,45 @@ const config: StorybookConfig = {
},
// 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"],
staticDirs: ["../public"],
viteFinal: async (config) => {
// Wire the @portal/* alias directly on the Storybook bundler so portal
// Wire the @processor/* 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, "../editor/src/portal"),
"@processor": resolve(__dirname, "../src/processor/proprietary"),
// 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"),
"@proprietary": resolve(__dirname, "../src/editor/proprietary"),
"@core": resolve(__dirname, "../src/editor/core"),
// Public assets (e.g. the en-US translation TOML loaded ?raw by preview.tsx).
// No src alias covers public/, so this lets the config use an alias rather
// than a relative path.
"@public": resolve(__dirname, "../editor/public"),
"@public": resolve(__dirname, "../public"),
};
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
// Editor stories import via @editor/* (proprietary→core fallback), @core/* and
// @proprietary/*. Resolve them exactly the way the editor's own build does —
// through vite-tsconfig-paths against the proprietary vite tsconfig — so the
// shared Storybook can host editor components without duplicating the alias
// map here.
config.plugins = config.plugins ?? [];
// Stories under desktop/, saas/, cloud/ and prototypes/ import @app/* too,
// but each of those builds resolves it against its own layer first — an
// order the single tsconfig below cannot express. Resolving by the
// importer's layer lets those stories load without changing what @app/*
// means for core, proprietary or portal stories, which never match here.
config.plugins.push(flavourAppAlias());
// Storybook's builder-vite auto-loads the app's vite.config.ts and merges in
// its plugins, but a Storybook build is not an app build. Drop the app's
// build-only plugins that emit deploy artifacts against a dist/ that a
// Storybook build never produces (prerender-og would otherwise throw ENOENT).
const APP_BUILD_ONLY = new Set(["prerender-og", "compress-static-copy"]);
config.plugins = config.plugins.filter((p) => {
const name =
p && typeof p === "object" && "name" in p
? (p as { name?: unknown }).name
: undefined;
return typeof name !== "string" || !APP_BUILD_ONLY.has(name);
});
config.plugins.push(
tsconfigPaths({
projects: [
resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"),
],
projects: [resolve(__dirname, "../tsconfig.proprietary.vite.json")],
}),
);
// Point apiClient.saas at a mock origin so the SaaS-backed billing stories
+14 -76
View File
@@ -14,19 +14,12 @@ import { withThemeByDataAttribute } from "@storybook/addon-themes";
void React;
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { PreferencesProvider } from "@core/contexts/PreferencesContext";
import { SidebarProvider } from "@core/contexts/SidebarContext";
import { SuiProvider } from "@portal/theme/SuiProvider";
import { MantineProvider } from "@mantine/core";
import {
mantineTheme as editorMantineTheme,
editorCssVariablesResolver,
} from "@core/theme/mantineTheme";
import { handlers } from "@portal/mocks/handlers";
import { TierProvider, type Tier } from "@processor/contexts/TierContext";
import { LinkProvider, type LinkState } from "@processor/contexts/LinkContext";
import { ThemeProvider, useTheme } from "@processor/contexts/ThemeContext";
import { UIProvider } from "@processor/contexts/UIContext";
import { SuiProvider } from "@processor/theme/SuiProvider";
import { handlers } from "@processor/mocks/handlers";
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
@@ -36,11 +29,6 @@ import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
import "@mantine/core/styles.css";
import "@core/tokens/tokens.css";
import "@core/theme/index.css";
// The editor's Mantine theme resolves its palette through the --color-* vocab
// defined here. The app picks this up via styles/tailwind.css; Storybook has no
// tailwind entry, so without it every var(--color-*) in the theme is undefined
// and Mantine silently falls back to its stock palette.
import "@core/styles/theme.css";
import "@core/tokens/base.css";
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
@@ -48,7 +36,7 @@ import "@core/tokens/base.css";
// async fetch (Storybook has no backend to serve /locales/). t(key) then renders
// the shipped copy (e.g. "No sources connected yet") rather than the raw key.
const localeModules = import.meta.glob<string>(
"../editor/public/locales/*/translation.toml",
"../public/locales/*/translation.toml",
{ query: "?raw", import: "default", eager: true },
);
@@ -202,37 +190,6 @@ const withLocale: Decorator = (Story, context) => {
return <Story />;
};
/**
* Applies the Mantine theme the story's component actually runs under in the
* app: PortalApp wraps the Processor in SuiProvider, while the editor wraps
* everything else in its own ThemeProvider. Getting this wrong is not just
* cosmetic — the two themes carry different neutral ramps, so rendering an
* editor component under the Processor's theme drops it onto Mantine's stock
* greys and reports contrast failures the app doesn't have.
*/
function StoryTheme({
isPortalStory,
colorScheme,
children,
}: {
isPortalStory: boolean;
colorScheme: "light" | "dark";
children: React.ReactNode;
}) {
if (isPortalStory) {
return <SuiProvider colorScheme={colorScheme}>{children}</SuiProvider>;
}
return (
<MantineProvider
theme={editorMantineTheme}
cssVariablesResolver={editorCssVariablesResolver}
forceColorScheme={colorScheme}
>
{children}
</MantineProvider>
);
}
const withProviders: Decorator = (Story, context) => {
const tier = (context.globals.tier as Tier) ?? "pro";
const linkState =
@@ -244,36 +201,25 @@ const withProviders: Decorator = (Story, context) => {
// anything that isn't "dark" as light — matching the addon's own
// `selected || defaultTheme` fallback where defaultTheme is light.
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
// Storybook titles are the routing key here: the Processor's stories are all
// filed under "Portal/".
const isPortalStory = (context.title ?? "").startsWith("Portal/");
return (
<MemoryRouter initialEntries={["/"]}>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<SchemeSetup scheme={colorScheme} />
<ThemeBridge theme={colorScheme}>
<StoryTheme isPortalStory={isPortalStory} colorScheme={colorScheme}>
<SuiProvider colorScheme={colorScheme}>
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
from useLink() (matches App.tsx's nesting). */}
<LinkProvider key={linkState} initialState={linkState}>
<TierKey tier={tier}>
{/* Tooltip reads the user's logo preference and the sidebar
geometry it positions against. It is used by ~100
components, so without these a story that renders one
throws. The real app always has both mounted. */}
<PreferencesProvider>
<SidebarProvider>
<UIProvider>
<Suspense fallback={null}>
<Story />
</Suspense>
</UIProvider>
</SidebarProvider>
</PreferencesProvider>
<UIProvider>
<Suspense fallback={null}>
<Story />
</Suspense>
</UIProvider>
</TierKey>
</LinkProvider>
</StoryTheme>
</SuiProvider>
</ThemeBridge>
</ThemeProvider>
</QueryClientProvider>
@@ -300,14 +246,6 @@ const preview: Preview = {
// any violation. Context is left at the addon default (the document root)
// so it resolves under both the Storybook UI and the Vitest browser mount.
test: "error",
context: {
// Nodes carrying this attribute render a facsimile of the user's own
// document — their stamp text, their watermark, in the colour and
// opacity they chose. WCAG contrast governs the interface, not the
// content authored through it, and the controls that set those values
// are checked normally.
exclude: ["[data-user-content-preview]"],
},
},
},
globalTypes: {
+6 -6
View File
@@ -1,15 +1,15 @@
{
// Extends the same tsconfig main.ts feeds to vite-tsconfig-paths, so the
// alias map can't drift from the one Storybook actually bundles with, and
// stories are checked under that resolution (@app/* = proprietary -> core).
// stories are checked under that resolution (@editor/* = proprietary -> core).
// The per-layer typecheck projects also cover story files, but each resolves
// @app/* its own way - not the way the single Storybook build renders them.
"extends": "../editor/tsconfig.proprietary.vite.json",
// @editor/* its own way - not the way the single Storybook build renders them.
"extends": "../tsconfig.proprietary.vite.json",
"exclude": [],
"include": [
"./**/*",
"../editor/src/global.d.ts",
"../editor/src/**/*.stories.ts",
"../editor/src/**/*.stories.tsx"
"../src/global.d.ts",
"../src/**/*.stories.ts",
"../src/**/*.stories.tsx"
]
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
* Dedicated Vitest config that turns every story into a browser test: it mounts
* the story in real Chromium and runs axe against it, so a story fails if it
* throws on mount or trips an accessibility rule. Kept separate from
* editor/vitest.config.ts (the jsdom unit tests) so the two suites don't collide.
* vitest.config.ts (the jsdom unit tests) so the two suites don't collide.
*
* The storybook test must live in a `test.projects[]` entry (not a flat config)
* so Vitest wires up the browser test runner correctly.
@@ -17,7 +17,7 @@ export default defineConfig({
optimizeDeps: {
// Pre-scan every story + the preview so Vite discovers the story set's large
// dep surface (embedpdf plugins, @mui icons, …) in one pass up front.
entries: ["editor/src/**/*.stories.@(ts|tsx)", ".storybook/preview.tsx"],
entries: ["src/**/*.stories.@(ts|tsx)", ".storybook/preview.tsx"],
// `entries` alone does not catch deps reached through a transformed JSX
// runtime import, nor the preview's own dependency graph (the test plugin
// injects the preview in a way the entry scanner doesn't crawl). Vite then
+1 -1
View File
@@ -1,7 +1,7 @@
import { beforeAll } from "vitest";
import { setProjectAnnotations } from "@storybook/react-vite";
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
// oxlint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias.
// eslint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias.
import * as projectAnnotations from "./preview";
// Include addon-a11y's annotations so its axe checks run under Vitest, not only
@@ -10,7 +10,7 @@ Refer to the various `tsconfig.*.json` files to see the specific path alias orde
The vast majority of the code is in the `src/core` folder, which is the open-source app.
Other builds, such as the desktop app, use `src/core` as the base layer, and then override various files to change behaviour.
If an import is `from '@app/a/b'`, this will refer to `src/core/a/b.ts` in the core build of the app, but may refer to `src/desktop/a/b.ts` in the desktop app if that file exists.
If an import is `from '@editor/a/b'`, this will refer to `src/editor/core/a/b.ts` in the core build of the app, but may refer to `src/editor/desktop/a/b.ts` in the desktop app if that file exists.
It is important to try to minimise the amount of overridden code in the app.
Often, just one function needs to behave differently in a specific mode.
@@ -27,7 +27,7 @@ In cases like this, instead of duplicating the entire file, create a new extensi
```ts
// core/file1.ts
import { f2 } from '@app/file1Extensions';
import { f2 } from '@editor/file1Extensions';
function f1() { /* ... */ }
function f3() { /* ... */ }
@@ -102,7 +102,7 @@ export function getToolbarButtons(): ToolbarButton[] {
```tsx
// core/Toolbar.tsx
import { getToolbarButtons } from '@app/toolbarExtensions';
import { getToolbarButtons } from '@editor/toolbarExtensions';
export function Toolbar() {
return (
@@ -123,7 +123,7 @@ This pattern works well for things like menu items or toolbar actions - anything
### Import aliases
In general, all imports for app code should come via `@app` because it allows for other builds of the app to override behaviour if necessary.
In general, all imports for app code should come via `@editor` because it allows for other builds of the app to override behaviour if necessary.
The only time that it is beneficial to import via a specific folder (e.g. `@core`) is when you want to reduce duplication **in the file you are overriding**. For example:
```ts
+4 -4
View File
@@ -6,7 +6,7 @@ All frontend commands are run from the repository root using [Task](https://task
- `task frontend:build` — production build
- `task frontend:test` — run tests
- `task frontend:test:watch` — run tests in watch mode
- `task frontend:lint` — run linting
- `task frontend:lint` — run ESLint + cycle detection
- `task frontend:typecheck` — run TypeScript type checking
- `task frontend:check` — run typecheck + lint + test
- `task frontend:install` — install npm dependencies
@@ -16,15 +16,15 @@ For desktop app development, see the [Tauri](#tauri) section below.
## Layout
`frontend/` is a workspace containing one or more apps. Today it holds the
PDF editor under `frontend/editor/`; new apps (the developer portal, etc.)
PDF editor under `frontend/`; new apps (the developer portal, etc.)
will sit alongside it as siblings. Shared tooling — `package.json`, `node_modules`,
`.storybook/`, oxlint, Prettier — lives at `frontend/` so every app installs
`.storybook/`, ESLint, Prettier — lives at `frontend/` so every app installs
once and lints with the same config.
## Environment Variables
The editor's environment variables live in committed `.env` files at
`frontend/editor/`:
`frontend/`:
- `.env` — used by all builds (core, proprietary, and as the base for desktop/SaaS)
- `.env.desktop` — additional vars loaded in desktop (Tauri) mode
@@ -1,241 +0,0 @@
#!/usr/bin/env node
// Storybook coverage report — which rendered surfaces have a story, and what
// stands between the ones that don't and having one. Modes:
//
// node storybook-coverage.mjs summary by area (report only)
// node storybook-coverage.mjs --todo every uncovered surface, with the
// work each needs
// node storybook-coverage.mjs --area core/components/tools
//
// Coverage is counted by *import*, not by an adjacent .stories.tsx: several
// components are covered by a shared story file (MantineForms covers Select,
// MultiSelect, NumberInput and ColorInput between them), and counting siblings
// reports those as gaps and invites duplicate stories.
//
// Each uncovered surface is classified by what a story would have to supply:
//
// props no context to supply. Note this means "no provider needed", not
// "cheap" — a props-only component can still be expensive to
// story if its props are heavy (ButtonAppearanceOverlay wants
// real PDF bytes; AppConfigModalLazy lazy-loads the whole
// settings tree). Read the props before assuming it is quick.
// context it (or something it renders) reads a React context. The cheap
// fix is usually to export the context and hand the story the
// slice the component actually touches, rather than mounting the
// provider and whatever chain sits behind it.
// data it fetches, so the story needs MSW handlers
// router it reads router state
//
// Not counted as surfaces at all: providers, contexts, gates, routers, test
// helpers, and modules that return a config object rather than markup.
//
// Known blocker — four flavours cannot be storied as things stand.
//
// Storybook resolves @app/* through editor/tsconfig.proprietary.vite.json,
// which maps it to src/proprietary/* then src/core/* and excludes src/desktop.
// A file in another flavour that imports an @app/* asset living only in its own
// tree therefore fails to resolve, and the story file does not load at all:
//
// desktop 80 of 152 @app/ imports unresolvable
// saas 54 of 232
// cloud 28 of 77
// prototypes 4 of 40
// portal-saas 0 of 7 (fine)
//
// That accounts for the 0% areas below — it is a build-config gap, not
// neglect. Closing it means either per-flavour alias projects in
// .storybook/main.ts or hoisting the shared assets, and it is a decision with
// blast radius across every existing story, so it is deliberately not made
// here.
//
// Run from frontend/.
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, resolve } from "node:path";
const SRC = resolve(process.cwd(), "editor/src");
const args = process.argv.slice(2);
const wantTodo = args.includes("--todo");
const areaFilter = args.includes("--area")
? args[args.indexOf("--area") + 1]
: null;
/* ── file walk ────────────────────────────────────────────────────────────── */
function walk(dir, out = []) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.name.endsWith(".tsx")) out.push(full);
}
return out;
}
const all = walk(SRC);
const rel = (f) => relative(SRC, f).split("\\").join("/");
const storyFiles = all.filter((f) => f.endsWith(".stories.tsx"));
const sources = all.filter(
(f) => !f.endsWith(".stories.tsx") && !f.endsWith(".test.tsx"),
);
/* ── what the stories already reach ───────────────────────────────────────── */
const importedNames = new Set();
const importedPaths = new Set();
for (const f of storyFiles) {
const src = readFileSync(f, "utf8");
for (const m of src.matchAll(
/import\s+(?:type\s+)?(?:\{([^}]*)\}|(\w+))\s*(?:,\s*\{([^}]*)\})?\s*from\s+["']([^"']+)/g,
)) {
for (const group of [m[1], m[3]]) {
if (!group) continue;
for (const name of group.split(","))
importedNames.add(
name.trim().split(" as ")[0].replace("type ", "").trim(),
);
}
if (m[2]) importedNames.add(m[2]);
importedPaths.add(m[4]);
}
}
/* ── classification ───────────────────────────────────────────────────────── */
// Bridge: the viewer's *APIBridge components register an API into context and
// render null — wiring, like the rest of these.
const INFRA_NAME =
/(Provider|Providers|Context|Gate|Boundary|Mount|Router|Guard|Bridge)\.tsx$/;
const INFRA_DIR = /\/(contexts|test|tests|mocks|hooks|types|utils|api|data)\//;
const RENDERS = /return\s*\(?\s*<|=>\s*\(?\s*</;
// A module whose exported function returns a config object, not markup.
const CONFIG_FACTORY = /:\s*(SlideConfig|ToolFlowConfig|\w+Config)\s*\{/;
const DATA = /\buse(Query|Mutation|SWR|InfiniteQuery)\b|\bfetch\w*\(/;
const ROUTER = /\buse(Navigate|Params|Location|SearchParams)\b/;
const CONTEXT = /\buse[A-Z]\w*\(/g;
// Hooks that are plainly not context reads.
const LOCAL_HOOK =
/^use(State|Effect|Memo|Callback|Ref|Id|Reducer|Context|Translation|LayoutEffect|ImperativeHandle|Transition|DeferredValue|SyncExternalStore|Debounced\w*|Media\w*|Disclosure|Form)$/;
// Contexts .storybook/preview.tsx already mounts for every story. A component
// that reads only these needs no fixture work, so it counts as props-level.
const HARNESS_PROVIDED =
/^use(Preferences|SidebarContext|Tier|Link|UI|Theme|QueryClient|Navigate|Location|Params|SearchParams)$/;
const byPath = new Map(sources.map((f) => [rel(f), f]));
function localImports(src, fromRel) {
const out = [];
for (const m of src.matchAll(
/from\s+["'](@app\/|@core\/|\.\.?\/)([^"']+)/g,
)) {
const spec = m[1] + m[2];
const guess = spec.replace(/^@app\//, "core/").replace(/^@core\//, "core/");
for (const cand of [`${guess}.tsx`, `${guess}/index.tsx`]) {
if (byPath.has(cand)) out.push(cand);
}
void fromRel;
}
return out;
}
/** Does this file, or anything it renders, read a context? Depth-limited. */
function needsContext(relPath, seen = new Set(), depth = 0) {
if (depth > 3 || seen.has(relPath)) return false;
seen.add(relPath);
const file = byPath.get(relPath);
if (!file) return false;
const src = readFileSync(file, "utf8");
for (const m of src.matchAll(CONTEXT)) {
const name = m[0].slice(0, -1);
if (!LOCAL_HOOK.test(name) && !HARNESS_PROVIDED.test(name)) return true;
}
return localImports(src, relPath).some((child) =>
needsContext(child, seen, depth + 1),
);
}
const rows = [];
for (const file of sources) {
const r = rel(file);
const base = r.split("/").pop();
if (!/^[A-Z]/.test(base)) continue;
const src = readFileSync(file, "utf8");
if (!RENDERS.test(src)) continue;
if (INFRA_NAME.test(base) || INFRA_DIR.test("/" + r)) continue;
if (CONFIG_FACTORY.test(src)) continue;
const stem = base.replace(".tsx", "");
const tail = r.replace(".tsx", "");
const covered =
importedNames.has(stem) ||
[...importedPaths].some((p) => p.endsWith(tail) || p.endsWith("/" + stem));
let needs = "props";
if (DATA.test(src)) needs = "data";
else if (ROUTER.test(src)) needs = "router";
else if (needsContext(r)) needs = "context";
const parts = r.split("/");
rows.push({
area: parts.slice(0, Math.min(3, parts.length - 1)).join("/"),
file: r,
covered,
needs,
loc: src.split("\n").length,
});
}
/* ── report ───────────────────────────────────────────────────────────────── */
const shown = areaFilter
? rows.filter((r) => r.file.startsWith(areaFilter))
: rows;
const todo = shown.filter((r) => !r.covered);
if (wantTodo || areaFilter) {
const order = { props: 0, context: 1, router: 2, data: 3 };
for (const r of todo.sort(
(a, b) => order[a.needs] - order[b.needs] || a.loc - b.loc,
)) {
console.log(
` ${r.needs.padEnd(8)} ${String(r.loc).padStart(5)} loc ${r.file}`,
);
}
console.log("");
}
const areas = new Map();
for (const r of shown) {
const a = areas.get(r.area) ?? { total: 0, covered: 0 };
a.total += 1;
if (r.covered) a.covered += 1;
areas.set(r.area, a);
}
console.log(
`${"area".padEnd(38)}${"total".padStart(6)}${"covered".padStart(9)}${"%".padStart(6)}`,
);
for (const [area, a] of [...areas].sort(
(x, y) => y[1].total - y[1].covered - (x[1].total - x[1].covered),
)) {
if (a.total === a.covered) continue;
const pct = Math.round((100 * a.covered) / a.total);
console.log(
`${area.padEnd(38)}${String(a.total).padStart(6)}${String(a.covered).padStart(9)}${String(pct).padStart(5)}%`,
);
}
const covered = shown.filter((r) => r.covered).length;
const byNeed = todo.reduce(
(acc, r) => ((acc[r.needs] = (acc[r.needs] ?? 0) + 1), acc),
{},
);
console.log(
`\nsurfaces ${shown.length} covered ${covered} (${Math.round((100 * covered) / shown.length)}%) remaining ${todo.length}`,
);
console.log(
`remaining by what a story needs: ` +
Object.entries(byNeed)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${k} ${v}`)
.join(" "),
);
-24
View File
@@ -1,24 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@app/*": [
"../../src/cloud/*",
"../../src/proprietary/*",
"../../src/core/*"
],
"@portal/*": ["../../src/portal/*"],
"@cloud/*": ["../../src/cloud/*"],
"@proprietary/*": ["../../src/proprietary/*"],
"@core/*": ["../../src/core/*"]
}
},
"include": [
"../global.d.ts",
"../*.js",
"../*.ts",
"../*.tsx",
"../core/setupTests.ts",
"."
]
}
@@ -1,42 +0,0 @@
/**
* The trailing card in the file editor's grid that invites the user to add more
* files. Clicking the card (or its "Add Files" button) opens the files modal;
* the smaller button beside it goes straight to the native file picker.
*
* The two buttons share one slot: hovering the upload button expands it to fill
* the row and hides "Add Files". That is internal state, so it is not a story.
* `accept` and `multiple` only reach the hidden input and change nothing on
* screen, which leaves a single rendered state.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import AddFileCard from "@app/components/fileEditor/AddFileCard";
import {
FilesModalContext,
type FilesModalContextType,
} from "@app/contexts/FilesModalContext";
const meta = {
title: "FileEditor/AddFileCard",
component: AddFileCard,
parameters: { layout: "centered" },
args: { onFileSelect: () => {} },
decorators: [
(Story) => (
// Only openFilesModal is read. The real provider reaches FileContext and
// NavigationContext, so a slice is supplied instead.
<FilesModalContext.Provider
value={{ openFilesModal: () => {} } as unknown as FilesModalContextType}
>
{/* The card fills the cell the file editor's grid gives it. */}
<div style={{ width: "16rem", height: "20rem" }}>
<Story />
</div>
</FilesModalContext.Provider>
),
],
} satisfies Meta<typeof AddFileCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -1,61 +0,0 @@
/**
* The three-column arrangement of the file manager modal on a wide viewport:
* sources on the left, the search/actions/list stack in the middle, and the
* selected file's details on the right.
*
* The layout takes no props — everything comes from the provider. The search
* bar and action bar above the list are tied to the Recent source, and the
* list's scroll height is computed from the modal height and whether any files
* exist, so the populated and empty cases are laid out differently.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import DesktopLayout from "@app/components/fileManager/DesktopLayout";
import {
makeStub,
withFileManager,
} from "@app/components/fileManager/storyFixtures";
const FILES = [
makeStub("file-1", "quarterly-report.pdf"),
makeStub("file-2", "invoice-2026-01.pdf"),
makeStub("file-3", "scan-of-contract.pdf", { size: 18_400_000 }),
];
const meta = {
title: "FileManager/DesktopLayout",
component: DesktopLayout,
parameters: { layout: "fullscreen" },
decorators: [
(Story) => (
<div style={{ height: "600px" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof DesktopLayout>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Files present and one of them selected, so the details column is filled in. */
export const Default: Story = {
decorators: [
withFileManager({ recentFiles: FILES, activeFileIds: [FILES[0].id] }),
],
};
/** First run: the middle column is the empty state and the details are blank. */
export const Empty: Story = {
decorators: [withFileManager({ recentFiles: [] })],
};
/** With storage on, the middle column gains the filter and bulk cloud actions. */
export const StorageEnabled: Story = {
decorators: [
withFileManager({
recentFiles: FILES,
activeFileIds: [FILES[0].id],
config: { storageEnabled: true, storageSharingEnabled: true },
}),
],
};
@@ -1,65 +0,0 @@
/**
* What the file manager shows before anything has been added. The copy and
* icons come from the file-action hooks, which desktop builds override — these
* stories render the web wording.
*
* Only one context field is read (the upload click handler), so the fixture
* supplies that alone rather than the whole provider.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import EmptyFilesState from "@app/components/fileManager/EmptyFilesState";
import {
FileManagerContext,
type FileManagerContextValue,
} from "@app/contexts/FileManagerContext";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
const meta: Meta<typeof EmptyFilesState> = {
title: "FileManager/EmptyFilesState",
component: EmptyFilesState,
parameters: { layout: "fullscreen" },
decorators: [
// The wordmark resolves its logo variant through PreferencesContext, which
// the Storybook preview does not mount, so this story supplies it.
(Story) => (
<PreferencesProvider>
<FileManagerContext.Provider
value={
{ onLocalFileClick: () => {} } as unknown as FileManagerContextValue
}
>
<div style={{ height: "32rem" }}>
<Story />
</div>
</FileManagerContext.Provider>
</PreferencesProvider>
),
],
};
export default meta;
type Story = StoryObj<typeof EmptyFilesState>;
export const Default: Story = {};
/** The panel centres itself, so a short container crops rather than reflows. */
export const ShortContainer: Story = {
decorators: [
(Story) => (
<div style={{ height: "18rem" }}>
<Story />
</div>
),
],
};
/** Narrow widths are the mobile case — the upload actions stack. */
export const Narrow: Story = {
decorators: [
(Story) => (
<div style={{ width: 360 }}>
<Story />
</div>
),
],
};
@@ -1,112 +0,0 @@
/**
* The action bar above the recent-files list: select-all, an optional storage
* filter, the selection count, and the bulk delete/download/upload/share
* buttons.
*
* What appears is decided by the storage config and by the current selection.
* The upload button needs storage on; the share button additionally needs
* sharing and share links on, which also widen the filter from All/Local to
* include the two "shared" tabs. The delete and download buttons are always
* present but disabled until something is selected, and the whole bar renders
* nothing at all while there are no recent files.
*
* Selection is provider state seeded from `activeFileIds`, so the stories vary
* that rather than a prop.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import FileActions from "@app/components/fileManager/FileActions";
import type { FileId } from "@app/types/file";
import {
makeStub,
withFileManager,
} from "@app/components/fileManager/storyFixtures";
const FILES = [
makeStub("file-1", "quarterly-report.pdf"),
makeStub("file-2", "invoice-2026-01.pdf"),
makeStub("file-3", "scan.pdf"),
];
const meta = {
title: "FileManager/FileActions",
component: FileActions,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof FileActions>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Local-only build, nothing selected: bulk actions present but inert. */
export const Default: Story = {
decorators: [withFileManager({ recentFiles: FILES })],
};
/** With a selection the count appears and delete/download become live. */
export const WithSelection: Story = {
decorators: [
withFileManager({
recentFiles: FILES,
activeFileIds: [FILES[0].id, FILES[1].id],
}),
],
};
/** Storage on adds the All/Local filter and the bulk upload button. */
export const StorageEnabled: Story = {
decorators: [
withFileManager({
recentFiles: FILES,
activeFileIds: [FILES[0].id],
config: { storageEnabled: true },
}),
],
};
/**
* Sharing and share links on add the share button and the two "shared" filter
* tabs.
*/
export const SharingEnabled: Story = {
decorators: [
withFileManager({
recentFiles: FILES,
activeFileIds: [FILES[0].id],
config: {
storageEnabled: true,
storageSharingEnabled: true,
storageShareLinksEnabled: true,
},
}),
],
};
/**
* A selection that includes a file owned by someone else: bulk upload and share
* stay disabled because they only apply to files the user owns.
*/
export const SelectionIncludesSharedFile: Story = {
decorators: [
withFileManager({
recentFiles: [
FILES[0],
makeStub("file-shared", "budget-from-alex.pdf", {
remoteStorageId: 42,
remoteOwnedByCurrentUser: false,
remoteOwnerUsername: "alex",
remoteAccessRole: "viewer",
}),
],
activeFileIds: [FILES[0].id, "file-shared" as FileId],
config: {
storageEnabled: true,
storageSharingEnabled: true,
storageShareLinksEnabled: true,
},
}),
],
};
/** With no recent files the bar renders nothing. */
export const NoFiles: Story = {
decorators: [withFileManager({ recentFiles: [] })],
};
@@ -1,123 +0,0 @@
/**
* The details card on the right of the file manager: name, format, size, date
* and version, followed by whatever the file's storage situation warrants.
*
* The trailing rows are all conditional and are driven by the stub's remote
* fields rather than by props. A file with no `remoteStorageId` is local only;
* one the user owns on the server shows a sync state that turns to "changes not
* uploaded" once its local timestamp is newer than the remote one; one owned by
* somebody else shows the owner and offers a copy. Sharing rows additionally
* need the storage/sharing config on, and a tool chain appears only when the
* file carries history.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import FileInfoCard from "@app/components/fileManager/FileInfoCard";
import {
makeStub,
withFileManager,
} from "@app/components/fileManager/storyFixtures";
const MODIFIED = Date.UTC(2026, 0, 14);
/** The default local-only build. */
const local = withFileManager();
/** Storage plus sharing, for the stories about server-side state. */
const cloud = withFileManager({
config: {
storageEnabled: true,
storageSharingEnabled: true,
storageShareLinksEnabled: true,
},
});
const meta = {
title: "FileManager/FileInfoCard",
component: FileInfoCard,
args: { modalHeight: "600px" },
decorators: [
(Story) => (
<div style={{ width: "22rem" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof FileInfoCard>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A file that has never left the browser: the "Local only" badge, nothing else. */
export const LocalOnly: Story = {
args: { currentFile: makeStub("file-1", "quarterly-report.pdf") },
decorators: [local],
};
/** No file selected — the labels stay, their values are blank. */
export const NoFileSelected: Story = {
args: { currentFile: null },
decorators: [local],
};
/** A file that has been through tools carries its chain as badges. */
export const WithToolHistory: Story = {
args: {
currentFile: makeStub("file-1", "quarterly-report.pdf", {
versionNumber: 3,
toolHistory: [
{ toolId: "split", timestamp: MODIFIED },
{ toolId: "compress", timestamp: MODIFIED },
],
}),
},
decorators: [local],
};
/** Uploaded and current: the cloud row reads "Synced" and shows the sync time. */
export const SyncedToCloud: Story = {
args: {
currentFile: makeStub("file-1", "quarterly-report.pdf", {
remoteStorageId: 7,
remoteStorageUpdatedAt: MODIFIED + 60_000,
}),
},
decorators: [cloud],
};
/** Edited since the last upload, so the cloud row warns instead. */
export const ChangesNotUploaded: Story = {
args: {
currentFile: makeStub("file-1", "quarterly-report.pdf", {
lastModified: MODIFIED + 3_600_000,
createdAt: MODIFIED + 3_600_000,
remoteStorageId: 7,
remoteStorageUpdatedAt: MODIFIED,
}),
},
decorators: [cloud],
};
/** Someone else's file: owner row, "Shared with you" badge, and a copy action. */
export const SharedWithYou: Story = {
args: {
currentFile: makeStub("file-1", "budget-from-alex.pdf", {
remoteStorageId: 11,
remoteOwnedByCurrentUser: false,
remoteOwnerUsername: "alex",
remoteAccessRole: "viewer",
}),
},
decorators: [cloud],
};
/** The user's own file with links out: a sharing row and the management entry. */
export const SharedByYou: Story = {
args: {
currentFile: makeStub("file-1", "quarterly-report.pdf", {
remoteStorageId: 7,
remoteStorageUpdatedAt: MODIFIED + 60_000,
remoteHasShareLinks: true,
}),
},
decorators: [cloud],
};
@@ -1,70 +0,0 @@
/**
* The scrolling list that fills the file manager. Which branch it renders is
* decided entirely by context — the active source, whether files are loading,
* and whether any survive the search filter — so the stories drive it through
* the provider rather than through props.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import FileListArea from "@app/components/fileManager/FileListArea";
import {
makeStub,
withFileManager,
} from "@app/components/fileManager/storyFixtures";
import type { FileId } from "@app/types/file";
const FILES = [
makeStub("f1", "quarterly-report.pdf"),
makeStub("f2", "signed-contract.pdf", { size: 840_000 }),
makeStub("f3", "scan-2026-03-14.pdf", { size: 12_600_000 }),
makeStub("f4", "minutes.pdf", { size: 96_000 }),
];
const meta: Meta<typeof FileListArea> = {
title: "FileManager/FileListArea",
component: FileListArea,
parameters: { layout: "padded" },
args: { scrollAreaHeight: "26rem" },
};
export default meta;
type Story = StoryObj<typeof FileListArea>;
export const Default: Story = {
decorators: [withFileManager({ recentFiles: FILES })],
};
/** No files at all — the list gives way to the empty state. */
export const Empty: Story = {
decorators: [withFileManager({ recentFiles: [] })],
};
export const Loading: Story = {
decorators: [withFileManager({ recentFiles: FILES, isLoading: true })],
};
/** Files already open in the editor are marked as active in the list. */
export const WithActiveFile: Story = {
decorators: [
withFileManager({
recentFiles: FILES,
activeFileIds: ["f2" as FileId],
}),
],
};
/** Enough rows to scroll, which is the normal state for a working library. */
export const ManyFiles: Story = {
decorators: [
withFileManager({
recentFiles: Array.from({ length: 24 }, (_, i) =>
makeStub(`many-${i}`, `document-${String(i + 1).padStart(3, "0")}.pdf`),
),
}),
],
};
/** A short frame proves the list scrolls inside its own box, not the page. */
export const ShortFrame: Story = {
args: { scrollAreaHeight: "12rem" },
decorators: [withFileManager({ recentFiles: FILES })],
};
@@ -1,142 +0,0 @@
/**
* A single row in the file manager's recent list: checkbox, name, a run of
* status badges, size/date, and a hover-revealed overflow menu.
*
* The badges are the interesting part. Version always shows; "Active" comes
* from the prop; and the storage badge is one of a mutually exclusive set
* decided by the stub's remote fields — local only, synced, changes not
* uploaded, or a shared-with-you pair of ownership and role — most of which
* additionally require the storage/sharing config to be on. `isHistoryFile`
* turns the row into an indented, non-selectable version entry instead.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import FileListItem from "@app/components/fileManager/FileListItem";
import {
makeStub,
withFileManager,
} from "@app/components/fileManager/storyFixtures";
const MODIFIED = Date.UTC(2026, 0, 14);
const FILE = makeStub("file-1", "quarterly-report.pdf");
/** The default local-only build. */
const local = withFileManager({ recentFiles: [FILE] });
/** Storage plus sharing, for the stories about server-side state. */
const cloud = withFileManager({
recentFiles: [FILE],
config: {
storageEnabled: true,
storageSharingEnabled: true,
storageShareLinksEnabled: true,
},
});
const meta = {
title: "FileManager/FileListItem",
component: FileListItem,
parameters: { layout: "fullscreen" },
args: {
file: FILE,
isSelected: false,
isLatestVersion: true,
onSelect: () => {},
onRemove: () => {},
onDownload: () => {},
onDoubleClick: () => {},
},
} satisfies Meta<typeof FileListItem>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A freshly uploaded file that has never been to the server. */
export const Default: Story = { decorators: [local] };
/** Selected: the checkbox is ticked and the row takes the highlight fill. */
export const Selected: Story = {
args: { isSelected: true },
decorators: [local],
};
/** A file currently open in the workbench earns the "Active" badge. */
export const Active: Story = {
args: { isActive: true },
decorators: [local],
};
/** A processed file: a higher version number and the tool chain that produced it. */
export const Processed: Story = {
args: {
file: makeStub("file-1", "quarterly-report.pdf", {
versionNumber: 3,
toolHistory: [
{ toolId: "split", timestamp: MODIFIED },
{ toolId: "compress", timestamp: MODIFIED },
],
}),
},
decorators: [local],
};
/**
* An older version listed under its leaf: indented behind a rule, with no
* checkbox because history entries cannot be selected.
*/
export const HistoryEntry: Story = {
args: {
file: makeStub("file-1", "quarterly-report.pdf", { versionNumber: 2 }),
isHistoryFile: true,
isLatestVersion: false,
},
decorators: [local],
};
/** Uploaded and current: "Synced" replaces the local-only badge. */
export const Synced: Story = {
args: {
file: makeStub("file-1", "quarterly-report.pdf", {
remoteStorageId: 7,
remoteStorageUpdatedAt: MODIFIED + 60_000,
}),
},
decorators: [cloud],
};
/** Edited since the last upload, so the badge warns instead. */
export const ChangesNotUploaded: Story = {
args: {
file: makeStub("file-1", "quarterly-report.pdf", {
lastModified: MODIFIED + 3_600_000,
createdAt: MODIFIED + 3_600_000,
remoteStorageId: 7,
remoteStorageUpdatedAt: MODIFIED,
}),
},
decorators: [cloud],
};
/** Someone else's file: ownership and the read-only role are both called out. */
export const SharedWithYou: Story = {
args: {
file: makeStub("file-1", "budget-from-alex.pdf", {
remoteStorageId: 11,
remoteOwnedByCurrentUser: false,
remoteOwnerUsername: "alex",
remoteAccessRole: "viewer",
}),
},
decorators: [cloud],
};
/** The user's own file with a live link out. */
export const SharedByYou: Story = {
args: {
file: makeStub("file-1", "quarterly-report.pdf", {
remoteStorageId: 7,
remoteStorageUpdatedAt: MODIFIED + 60_000,
remoteHasShareLinks: true,
}),
},
decorators: [cloud],
};
@@ -1,74 +0,0 @@
/**
* The source picker down the left of the file manager: Recent, local upload,
* Google Drive and mobile scan.
*
* Recent and upload are always offered. The other two are each governed by a
* pair of config flags — one that enables the integration and one that decides
* whether an unavailable integration is shown greyed out or dropped from the
* list entirely. `horizontal` reflows the same buttons into a centred row for
* the mobile layout and shortens their labels ("Drive", "Mobile").
*/
import type { ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import FileSourceButtons from "@app/components/fileManager/FileSourceButtons";
import { withFileManager } from "@app/components/fileManager/storyFixtures";
/** The column the buttons occupy in the desktop layout. */
const withColumn = (Story: () => ReactElement) => (
<div style={{ width: "13.625rem", height: "20rem" }}>
<Story />
</div>
);
const meta = {
title: "FileManager/FileSourceButtons",
component: FileSourceButtons,
decorators: [withColumn],
} satisfies Meta<typeof FileSourceButtons>;
export default meta;
type Story = StoryObj<typeof meta>;
/**
* The plain build: Recent is the active source, and Drive and mobile scan are
* both present but disabled because neither is configured.
*/
export const Default: Story = {
decorators: [withFileManager()],
};
/** Hiding the unavailable integrations leaves only Recent and upload. */
export const UnavailableSourcesHidden: Story = {
decorators: [
withFileManager({
config: {
hideDisabledToolsGoogleDrive: true,
hideDisabledToolsMobileQRScanner: true,
},
}),
],
};
/**
* Fully configured: Drive takes its coloured icon and both integrations become
* clickable. Drive needs the client/API/app ids as well as its enable flag.
*/
export const AllSourcesAvailable: Story = {
decorators: [
withFileManager({
config: {
googleDriveEnabled: true,
googleDriveClientId: "storybook-client-id",
googleDriveApiKey: "storybook-api-key",
googleDriveAppId: "storybook-app-id",
enableMobileScanner: true,
},
}),
],
};
/** The mobile layout's row: centred, no heading, and abbreviated labels. */
export const Horizontal: Story = {
args: { horizontal: true },
decorators: [withFileManager()],
};
@@ -1,55 +0,0 @@
/**
* The stacked arrangement of the file manager modal on a narrow viewport: the
* sources as a horizontal row, the compact file details, then the search bar,
* action bar and list sharing one panel.
*
* As with the desktop layout there are no props — the provider supplies
* everything. The search and action bars belong to the Recent source, and the
* list's height is worked back from the modal height, allowing extra room for
* the details block once a file is selected.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import MobileLayout from "@app/components/fileManager/MobileLayout";
import {
makeStub,
withFileManager,
} from "@app/components/fileManager/storyFixtures";
const FILES = [
makeStub("file-1", "quarterly-report.pdf"),
makeStub("file-2", "invoice-2026-01.pdf"),
];
const meta = {
title: "FileManager/MobileLayout",
component: MobileLayout,
parameters: { layout: "fullscreen" },
globals: { viewport: { value: "mobile1", isRotated: false } },
decorators: [
(Story) => (
<div style={{ height: "600px" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof MobileLayout>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A file selected, so the compact details block sits above the list. */
export const Default: Story = {
decorators: [
withFileManager({ recentFiles: FILES, activeFileIds: [FILES[0].id] }),
],
};
/** Nothing selected: the details block shrinks and the list takes the room. */
export const NoSelection: Story = {
decorators: [withFileManager({ recentFiles: FILES })],
};
/** First run, with the empty state filling the list panel. */
export const Empty: Story = {
decorators: [withFileManager({ recentFiles: [] })],
};
@@ -1,70 +0,0 @@
/**
* The file manager's search box. It reads the term and the change handler from
* FileManagerContext rather than taking props, so the stories mount it against
* a two-field slice of that context instead of the whole provider chain.
*/
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import SearchInput from "@app/components/fileManager/SearchInput";
import {
FileManagerContext,
type FileManagerContextValue,
} from "@app/contexts/FileManagerContext";
/**
* SearchInput reads exactly two fields. Supplying only those keeps the fixture
* honest about what the component depends on; the cast is what makes a partial
* value acceptable in place of the full context.
*/
function withSearch(
searchTerm: string,
onSearchChange: (term: string) => void = () => {},
) {
return (children: React.ReactNode) => (
<FileManagerContext.Provider
value={
{ searchTerm, onSearchChange } as unknown as FileManagerContextValue
}
>
{children}
</FileManagerContext.Provider>
);
}
const meta: Meta<typeof SearchInput> = {
title: "FileManager/SearchInput",
component: SearchInput,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof SearchInput>;
export const Empty: Story = {
render: () => withSearch("")(<SearchInput />),
};
export const WithTerm: Story = {
render: () => withSearch("invoice")(<SearchInput />),
};
/** Long terms are not truncated by the component — the field scrolls instead. */
export const LongTerm: Story = {
render: () =>
withSearch("quarterly-report-2026-final-revised-approved")(<SearchInput />),
};
/** The container controls width, so the box stretches to whatever it is given. */
export const Narrow: Story = {
render: () => (
<div style={{ width: 220 }}>{withSearch("draft")(<SearchInput />)}</div>
),
};
/** Typing is driven by the context handler, so state lives outside the field. */
export const Interactive: Story = {
render: function Interactive() {
const [term, setTerm] = useState("");
return withSearch(term, setTerm)(<SearchInput />);
},
};
@@ -1,98 +0,0 @@
/**
* Shared fixtures for the file manager stories.
*
* These components sit deep in a provider chain — FileManagerContext needs
* FileContext for useFileActions/useFileManagement, list rows additionally read
* AppConfig — and none of it is part of the shared preview decorators. Rather
* than each story rebuilding that tree, they mount the real providers here over
* static data, so what a story exercises is the component and not a stub.
*/
import type { ReactElement } from "react";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { FileContextProvider } from "@app/contexts/FileContext";
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
/** A grey rectangle, so thumbnail lookups short-circuit instead of reading
* file bytes out of IndexedDB. */
const THUMBNAIL =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E";
/** Fixed so stories render identically on every run. */
const LAST_MODIFIED = Date.parse("2026-03-14T09:30:00Z");
export function makeStub(
id: string,
name: string,
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub {
return {
id: id as FileId,
name,
type: "application/pdf",
size: 2_400_000,
lastModified: LAST_MODIFIED,
isLeaf: true,
originalFileId: id,
versionNumber: 1,
thumbnailUrl: THUMBNAIL,
...overrides,
};
}
export const mockFile = makeStub("story-file-1", "quarterly-report.pdf");
/** Storage off keeps the row's upload and share affordances out of the way;
* stories that want them pass their own config. */
const BASE_CONFIG = {
storageEnabled: false,
storageSharingEnabled: false,
storageShareLinksEnabled: false,
frontendUrl: "https://stirling.example",
};
interface FixtureOptions {
recentFiles?: StirlingFileStub[];
activeFileIds?: FileId[];
isLoading?: boolean;
config?: Partial<typeof BASE_CONFIG>;
}
export function withFileManager({
recentFiles = [mockFile],
activeFileIds = [],
isLoading = false,
config,
}: FixtureOptions = {}) {
return (Story: () => ReactElement) => (
<AppConfigProvider
initialConfig={{ ...BASE_CONFIG, ...config } as never}
bootstrapMode="non-blocking"
autoFetch={false}
>
{/* The empty state's wordmark resolves a logo variant through
PreferencesContext, which the preview does not mount. */}
<PreferencesProvider>
<FileContextProvider>
<FileManagerProvider
recentFiles={recentFiles}
onRecentFilesSelected={() => {}}
onNewFilesSelect={() => {}}
onClose={() => {}}
isFileSupported={() => true}
isOpen
onFileRemove={() => {}}
modalHeight="600px"
refreshRecentFiles={async () => {}}
isLoading={isLoading}
activeFileIds={activeFileIds}
>
<Story />
</FileManagerProvider>
</FileContextProvider>
</PreferencesProvider>
</AppConfigProvider>
);
}
@@ -1,58 +0,0 @@
/**
* The resizable rail that holds the folder tree on the Files page. It supplies
* the heading and the drag/keyboard resize handle around FolderTreeSidebar, and
* wires the tree's folder actions through to the Files page context.
*
* `active` is the only prop: an inactive panel is collapsed away by CSS, hidden
* from assistive technology, and drops its resize handle, so the tab it belongs
* to can slide in and out. Its width is otherwise self-managed — auto-fitted to
* the longest folder name until the user drags it, after which the chosen width
* is persisted.
*
* No folders are seeded into IndexedDB, so the tree shows only its pinned rows.
*/
import type { ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
import { FileContextProvider } from "@app/contexts/FileContext";
import { FolderProvider } from "@app/contexts/FolderContext";
import { FilesPageProvider } from "@app/contexts/FilesPageContext";
/**
* The panel reads the folder tree from FolderContext and the file counts and
* folder dialogs from FilesPageContext; both sit above FileContext, which
* brings in IndexedDBContext. None are part of the shared preview decorators.
*/
function withFolderContexts(Story: () => ReactElement) {
return (
<FileContextProvider>
<FolderProvider>
<FilesPageProvider>
<div style={{ display: "flex", height: "24rem" }}>
<Story />
</div>
</FilesPageProvider>
</FolderProvider>
</FileContextProvider>
);
}
const meta = {
title: "FilesPage/FolderTreePanel",
component: FolderTreePanel,
parameters: { layout: "fullscreen" },
decorators: [withFolderContexts],
} satisfies Meta<typeof FolderTreePanel>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Open: the heading, the tree, and the resize handle down the right edge. */
export const Active: Story = {
args: { active: true },
};
/** Collapsed for a tab that is not showing folders. */
export const Inactive: Story = {
args: { active: false },
};
@@ -1,327 +0,0 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge, Group, Menu, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import HistoryIcon from "@mui/icons-material/History";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import { FileId, ToolOperation } from "@app/types/file";
import { ToolId } from "@app/types/toolId";
import { StirlingFileStub } from "@app/types/fileContext";
import { formatFileSize, getFileDate } from "@app/utils/fileUtils";
import { downloadFileFromStorage } from "@app/utils/downloadUtils";
/** Small label/value row with crisp flex alignment and colon separation. */
export function DetailField({
label,
value,
}: {
label: string;
value: string;
}) {
return (
<div
className="files-page-details-field"
style={{
display: "flex",
gap: "0.5rem",
alignItems: "baseline",
wordBreak: "break-all",
}}
>
<span
className="files-page-details-field-label"
style={{
fontWeight: 500,
color: "var(--c-text-subtle)",
flexShrink: 0,
}}
>
{label}:
</span>
<span
className="files-page-details-field-value"
style={{ fontWeight: 600, color: "var(--c-text)" }}
>
{value}
</span>
</div>
);
}
/** Tool that produced `version` from `prior`; null for v1. */
function deltaToolFor(
version: StirlingFileStub,
prior: StirlingFileStub | null,
): ToolOperation | null {
if (!prior) return null;
const priorLen = prior.toolHistory?.length ?? 0;
const curr = version.toolHistory ?? [];
return curr[priorLen] ?? null;
}
/** Translated tool name via `home.{toolId}.title`. */
function ToolLabel({ toolId }: { toolId: ToolId }) {
const { t } = useTranslation();
return <span>{t(`home.${toolId}.title`, toolId)}</span>;
}
export interface VersionTimelineProps {
/** Chain sorted oldest-first. */
chain: StirlingFileStub[];
/** Currently selected version. */
currentId: FileId;
onAddToWorkspace: (fileIds: FileId[]) => void;
onRemove: (fileIds: FileId[]) => void;
hideHeader?: boolean;
}
/** Clean, spacious version timeline with minimal clutter. */
export function VersionTimeline({
chain,
currentId,
onAddToWorkspace,
onRemove,
hideHeader = false,
}: VersionTimelineProps) {
const { t } = useTranslation();
const [showAllCollapsed, setShowAllCollapsed] = useState(false);
// Newest-first ordering.
const ordered = useMemo(
() =>
[...chain].sort(
(a, b) => (b.versionNumber ?? 1) - (a.versionNumber ?? 1),
),
[chain],
);
// Index by versionNumber for prior-version lookup.
const byVersionNumber = useMemo(() => {
const map = new Map<number, StirlingFileStub>();
for (const v of chain) {
map.set(v.versionNumber ?? 1, v);
}
return map;
}, [chain]);
// Collapse middle when long: 3 newest + ellipsis + 2 oldest.
const COLLAPSE_THRESHOLD = 6;
const collapsible = ordered.length > COLLAPSE_THRESHOLD;
type Row =
| { kind: "version"; version: StirlingFileStub }
| {
kind: "ellipsis";
hidden: number;
};
const rows: Row[] = useMemo(() => {
if (!collapsible || showAllCollapsed) {
return ordered.map((v) => ({ kind: "version", version: v }) as Row);
}
const head = ordered
.slice(0, 3)
.map((v) => ({ kind: "version", version: v }) as Row);
const tail = ordered
.slice(-2)
.map((v) => ({ kind: "version", version: v }) as Row);
const hidden = ordered.length - 5;
return [...head, { kind: "ellipsis", hidden }, ...tail];
}, [collapsible, showAllCollapsed, ordered]);
return (
<div className="files-page-details-version-timeline">
{!hideHeader && (
<div className="files-page-details-version-timeline-label">
<HistoryIcon fontSize="small" />
<span>{t("filesPage.field.versionHistory", "Version journey")}</span>
<span className="files-page-details-version-timeline-count">
{t("filesPage.versionsCount", "{{count}} versions", {
count: ordered.length,
})}
</span>
</div>
)}
<ul
className="files-page-details-version-timeline-list"
style={{ listStyle: "none", padding: 0, margin: 0 }}
>
{rows.map((row, idx) => {
const isLast = idx === rows.length - 1;
if (row.kind === "ellipsis") {
return (
<li
key="ellipsis"
className="files-page-details-version-timeline-ellipsis"
style={{ listStyle: "none" }}
>
<div className="files-page-details-version-timeline-rail">
<span className="files-page-details-version-timeline-rail-dot is-ellipsis" />
{!isLast && (
<span className="files-page-details-version-timeline-rail-line" />
)}
</div>
<Button
variant="tertiary"
size="sm"
onClick={() => setShowAllCollapsed(true)}
>
{t(
"filesPage.versionShowHidden",
"Show {{count}} earlier versions",
{ count: row.hidden },
)}
</Button>
</li>
);
}
const v = row.version;
const isActive = v.id === currentId;
const prior = byVersionNumber.get((v.versionNumber ?? 1) - 1) ?? null;
const delta = deltaToolFor(v, prior);
const isOriginal = (v.versionNumber ?? 1) === 1;
const nameChanged = prior ? prior.name !== v.name : false;
return (
<li
key={v.id}
className={`files-page-details-version-timeline-row${
isActive ? " is-active" : ""
}`}
style={{ listStyle: "none" }}
>
<div className="files-page-details-version-timeline-rail">
<span
className={`files-page-details-version-timeline-rail-dot${
isActive ? " is-active" : ""
}`}
/>
{!isLast && (
<span className="files-page-details-version-timeline-rail-line" />
)}
</div>
<div className="files-page-details-version-timeline-body">
{/* Header row: Version Badge, Action Title + Menu */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
width: "100%",
}}
>
<Group
gap="sm"
align="center"
style={{ flex: 1, minWidth: 0, flexWrap: "nowrap" }}
>
<Badge size="xs" variant={isActive ? "filled" : "light"}>
v{v.versionNumber ?? 1}
</Badge>
<Text
fw={600}
size="sm"
truncate
style={{ color: "var(--c-text)" }}
>
{delta ? (
<ToolLabel toolId={delta.toolId} />
) : (
t("filesPage.versionOrigin", "Original upload")
)}
</Text>
</Group>
{!isActive && (
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<ActionIcon
variant="tertiary"
size="sm"
aria-label={t(
"filesPage.versionActions",
"Version actions",
)}
onClick={(e) => e.stopPropagation()}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={() => onAddToWorkspace([v.id])}
>
{t(
"filesPage.openVersionInWorkspace",
"Open in workspace",
)}
</Menu.Item>
<Menu.Item
leftSection={<DownloadIcon fontSize="small" />}
onClick={() => {
void downloadFileFromStorage(v);
}}
>
{t(
"filesPage.downloadVersion",
"Download this version",
)}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteIcon fontSize="small" />}
onClick={() => onRemove([v.id])}
>
{t("filesPage.removeVersion", "Remove this version")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</div>
{/* Quiet Meta Line: File Size · Date */}
<Text size="xs" c="dimmed" style={{ marginTop: "0.15rem" }}>
{formatFileSize(v.size)}
{v.lastModified && (
<> · {getFileDate({ lastModified: v.lastModified })}</>
)}
</Text>
{/* Show filename ONLY if original upload or if name changed */}
{(isOriginal || nameChanged) && (
<Text
size="xs"
c="dimmed"
style={{ wordBreak: "break-all", marginTop: "0.2rem" }}
>
{nameChanged
? `${t("filesPage.renamed", "Renamed")}: `
: `${t("filesPage.file", "File")}: `}
<span style={{ color: "var(--c-text)", fontWeight: 500 }}>
{v.name}
</span>
</Text>
)}
</div>
</li>
);
})}
</ul>
{collapsible && showAllCollapsed && (
<Button
variant="tertiary"
size="sm"
onClick={() => setShowAllCollapsed(false)}
>
{t("filesPage.versionCollapse", "Collapse middle versions")}
</Button>
)}
</div>
);
}
@@ -1,75 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
HotkeyContext,
type HotkeyContextValue,
} from "@app/contexts/HotkeyContext";
import { HotkeyDisplay } from "@app/components/hotkeys/HotkeyDisplay";
import { getDisplayParts } from "@app/utils/hotkeys";
/** A keyboard shortcut rendered as key caps.
*
* HotkeyProvider pulls in the whole tool-workflow chain, but the display only
* needs one function off the context — so the stories supply that slice, using
* the real formatter so the caps render exactly as they do in the app. Pinned
* to the non-mac glyphs to keep the stories stable across machines. */
const withHotkeys = (Story: React.ComponentType) => (
<HotkeyContext.Provider
value={
{
getDisplayParts: (binding) => getDisplayParts(binding, false),
} as HotkeyContextValue
}
>
<Story />
</HotkeyContext.Provider>
);
const meta: Meta<typeof HotkeyDisplay> = {
title: "Hotkeys/HotkeyDisplay",
component: HotkeyDisplay,
parameters: { layout: "centered" },
decorators: [withHotkeys],
};
export default meta;
type Story = StoryObj<typeof HotkeyDisplay>;
/** A single key. */
export const SingleKey: Story = { args: { binding: { code: "KeyS" } } };
/** The common save shortcut. */
export const WithModifier: Story = {
args: { binding: { code: "KeyS", ctrl: true } },
};
/** Several modifiers at once. */
export const MultipleModifiers: Story = {
args: { binding: { code: "KeyP", ctrl: true, shift: true, alt: true } },
};
/** The macOS command modifier. */
export const MetaModifier: Story = {
args: { binding: { code: "KeyK", meta: true } },
};
/** A non-letter key, which renders its own glyph rather than a letter. */
export const ArrowKey: Story = { args: { binding: { code: "ArrowRight" } } };
/** Both sizes side by side. */
export const Sizes: Story = {
render: () => (
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
<HotkeyDisplay binding={{ code: "KeyS", ctrl: true }} size="sm" />
<HotkeyDisplay binding={{ code: "KeyS", ctrl: true }} size="md" />
</div>
),
};
/** Muted, for a shortcut shown beside a disabled action. */
export const Muted: Story = {
args: { binding: { code: "KeyS", ctrl: true }, muted: true },
};
/** No binding assigned — the component renders nothing rather than an empty
* cap, so an unbound action shows no stray chrome. */
export const Unbound: Story = { args: { binding: null } };
@@ -1,244 +0,0 @@
import type { ReactNode } from "react";
import { Modal } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Button, type ButtonAccent } from "@app/ui/Button";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
import stirlingMark from "@app/assets/brand/modern-logo/logo512.png";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
/** A footer button. `action` is an opaque string handled by the caller. */
export interface ShellButton {
key: string;
/** Chevron-left icon button (a back control) instead of a labelled button. */
back?: boolean;
label?: string;
/** Filled primary (blue) vs. quiet text button. */
primary?: boolean;
/** Accent override for a primary button (e.g. "premium"). */
accent?: ButtonAccent;
action: string;
disabled?: boolean;
}
export interface OnboardingSlideShellProps {
opened?: boolean;
/** Hero art node — use {@link ShellHero} to render the app mark or a glyph. */
hero: ReactNode;
slideKey: string;
title: ReactNode;
body: ReactNode;
stepIndex: number;
stepCount: number;
buttons: ShellButton[];
onAction: (action: string) => void;
onClose: () => void;
allowDismiss?: boolean;
}
/**
* Hero art for the inset panel. `appIcon` renders the Stirling app mark
* directly; otherwise the children glyph sits inside a soft white tile.
*/
export function ShellHero({
appIcon = false,
children,
}: {
appIcon?: boolean;
children?: ReactNode;
}) {
if (appIcon) {
return (
<img src={stirlingMark} alt="Stirling" className={styles.heroAppIcon} />
);
}
return <div className={styles.heroTile}>{children}</div>;
}
/**
* Shared onboarding slide chrome: branded header + step progress, an inset
* hero panel, left-aligned title/body, and a right-aligned action footer.
* Generic over button actions so every flow (editor, SaaS, portal) renders
* the same card.
*/
export default function OnboardingSlideShell({
opened = true,
hero,
slideKey,
title,
body,
stepIndex,
stepCount,
buttons,
onAction,
onClose,
allowDismiss = true,
}: OnboardingSlideShellProps) {
const { t } = useTranslation();
const showProgress = stepCount > 1;
// Back/icon buttons anchor the left; text actions cluster on the right.
// A back control can't do anything on the first slide, so hide it there.
const backButtons = stepIndex === 0 ? [] : buttons.filter((b) => b.back);
const actionButtons = buttons.filter((b) => !b.back);
const renderButton = (button: ShellButton) => (
<Button
key={button.key}
onClick={() => onAction(button.action)}
disabled={button.disabled}
variant={button.primary ? "primary" : "quiet"}
accent={button.accent ?? (button.primary ? "default" : "neutral")}
>
{button.label}
</Button>
);
const actions = (
<div className={styles.footerGroup}>{actionButtons.map(renderButton)}</div>
);
return (
// Composed rather than the plain <Modal>, because only Modal.Content lands
// props on the role="dialog" element — the slide draws its own title, so the
// dialog needs an aria-label to have an accessible name.
<Modal.Root
opened={opened}
onClose={onClose}
closeOnClickOutside={false}
closeOnEscape={allowDismiss}
centered
size="lg"
radius={20}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
content: {
overflow: "hidden",
border: "none",
background: "var(--c-surface)",
maxHeight: "90vh",
},
}}
>
<Modal.Overlay />
<Modal.Content
radius={20}
aria-label={t("onboarding.dialogLabel", "Onboarding")}
>
<Modal.Body>
<div className={styles.card}>
<header className={styles.header}>
<div className={styles.brand}>
<img
src={stirlingMark}
alt=""
aria-hidden="true"
className={styles.brandLogo}
/>
<span className={styles.wordmark}>Stirling</span>
</div>
<div className={styles.headerRight}>
{showProgress && (
<span className={styles.stepPill}>
{t("onboarding.stepOf", "Step {{current}} of {{total}}", {
current: stepIndex + 1,
total: stepCount,
})}
</span>
)}
{allowDismiss && (
<ActionIcon
onClick={onClose}
variant="tertiary"
accent="neutral"
size="md"
aria-label={t("common.close", "Close")}
>
<LocalIcon
icon="close-rounded"
width="1.1rem"
height="1.1rem"
/>
</ActionIcon>
)}
</div>
</header>
{showProgress && (
<div
className={styles.progressTrack}
role="progressbar"
aria-valuenow={stepIndex + 1}
aria-valuemin={1}
aria-valuemax={stepCount}
aria-label={t(
"onboarding.stepOf",
"Step {{current}} of {{total}}",
{
current: stepIndex + 1,
total: stepCount,
},
)}
>
{Array.from({ length: stepCount }, (_, index) => (
<span
key={index}
className={`${styles.progressSeg} ${
index <= stepIndex ? styles.progressSegDone : ""
}`}
/>
))}
</div>
)}
<div className={styles.divider} />
<div className={styles.content}>
<div className={styles.heroPanel}>
<div className={styles.heroArt} key={`hero-${slideKey}`}>
{hero}
</div>
</div>
<div key={`title-${slideKey}`} className={styles.titleNew}>
{title}
</div>
<div key={`body-${slideKey}`} className={styles.bodyNew}>
{body}
<style>{`.${styles.bodyNew} strong{color: var(--c-text); font-weight: 600;}`}</style>
</div>
<div className={styles.footer}>
{backButtons.length === 0 ? (
<div className={styles.footerEnd}>{actions}</div>
) : (
<div className={styles.footerBetween}>
<div className={styles.footerGroup}>
{backButtons.map((button) => (
<ActionIcon
key={button.key}
onClick={() => onAction(button.action)}
variant="tertiary"
accent="neutral"
disabled={button.disabled}
aria-label={t("onboarding.buttons.back", "Back")}
>
<ChevronLeftIcon fontSize="small" />
</ActionIcon>
))}
</div>
{actions}
</div>
)}
</div>
</div>
</div>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
@@ -1,93 +0,0 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel";
/** A document of `n` pages in the shape the panel reads. */
const doc = (n: number) => ({
pages: Array.from({ length: n }, (_, i) => ({
id: `page-${i + 1}`,
pageNumber: i + 1,
})),
});
/**
* Selecting pages by typing a range rather than clicking thumbnails. The CSV
* field is the whole point of the panel, so the stories drive it with real
* state — typing into a static snapshot would prove nothing.
*/
const meta: Meta<typeof BulkSelectionPanel> = {
title: "PageEditor/BulkSelectionPanel",
component: BulkSelectionPanel,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof BulkSelectionPanel>;
function Demo({
initialCsv = "",
selected = [],
pages = 24,
}: {
initialCsv?: string;
selected?: string[];
pages?: number;
}) {
const [csvInput, setCsvInput] = useState(initialCsv);
return (
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPageIds={selected}
displayDocument={doc(pages)}
onUpdatePagesFromCSV={() => {}}
/>
);
}
/** Nothing selected yet. */
export const Empty: Story = { render: () => <Demo /> };
/** A typed range, with the matching pages selected. */
export const WithRange: Story = {
render: () => (
<Demo
initialCsv="1-5"
selected={["page-1", "page-2", "page-3", "page-4", "page-5"]}
/>
),
};
/** A mixed expression — individual pages and ranges together. */
export const MixedExpression: Story = {
render: () => (
<Demo initialCsv="1,4-6,12" selected={["page-1", "page-4", "page-12"]} />
),
};
/** Every page selected. */
export const AllSelected: Story = {
render: () => (
<Demo
pages={8}
initialCsv="1-8"
selected={Array.from({ length: 8 }, (_, i) => `page-${i + 1}`)}
/>
),
};
/** A single-page document, where ranges have little to do. */
export const SinglePage: Story = {
render: () => <Demo pages={1} />,
};
/** A long document, to check the summary stays readable as counts grow. */
export const LongDocument: Story = {
render: () => (
<Demo
pages={480}
initialCsv="1-200"
selected={Array.from({ length: 200 }, (_, i) => `page-${i + 1}`)}
/>
),
};
@@ -1,69 +0,0 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
const doc = (n: number) => ({
pages: Array.from({ length: n }, (_, i) => ({
id: `page-${i + 1}`,
pageNumber: i + 1,
})),
});
/**
* The toolbar affordance that opens bulk page selection. It disables itself
* when there are no pages to select, so an empty document offers no dead
* control.
*/
const meta: Meta<typeof PageSelectByNumberButton> = {
title: "PageEditor/PageSelectByNumberButton",
component: PageSelectByNumberButton,
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj<typeof PageSelectByNumberButton>;
function Demo({
totalPages = 24,
disabled = false,
initialCsv = "",
selected = [],
}: {
totalPages?: number;
disabled?: boolean;
initialCsv?: string;
selected?: string[];
}) {
const [csvInput, setCsvInput] = useState(initialCsv);
return (
<PageSelectByNumberButton
disabled={disabled}
totalPages={totalPages}
label="Select pages by number"
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPageIds={selected}
displayDocument={doc(totalPages)}
updatePagesFromCSV={() => {}}
/>
);
}
/** Available — click to open the selection popover. */
export const Default: Story = { render: () => <Demo /> };
/** A selection already in place. */
export const WithSelection: Story = {
render: () => (
<Demo initialCsv="2,5-9" selected={["page-2", "page-5", "page-9"]} />
),
};
/** Explicitly disabled, e.g. while the document is still loading. */
export const Disabled: Story = { render: () => <Demo disabled /> };
/** No pages: the control disables itself regardless of the `disabled` prop. */
export const NoPages: Story = { render: () => <Demo totalPages={0} /> };
/** A single page — selectable, but ranges have little to do. */
export const SinglePage: Story = { render: () => <Demo totalPages={1} /> };
@@ -1,48 +0,0 @@
/**
* The page-selection expression field at the top of the bulk selection panel —
* a title with a syntax-guide tooltip, an optional "Advanced" switch, and the
* comma/range input itself.
*
* Two things decide what renders. The clear button in the input's right section
* appears only while the expression is non-empty, and the Advanced switch is
* present only when the caller passes an `advancedOpened` boolean at all —
* panels that have no advanced mode omit the prop and get no switch.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import PageSelectionInput from "@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput";
const meta = {
title: "PageEditor/BulkSelectionPanel/PageSelectionInput",
component: PageSelectionInput,
args: {
csvInput: "",
setCsvInput: () => {},
onUpdatePagesFromCSV: () => {},
onClear: () => {},
},
} satisfies Meta<typeof PageSelectionInput>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Empty: placeholder syntax only, and no clear affordance to offer yet. */
export const Default: Story = {};
/** A non-empty expression reveals the clear button beside the input. */
export const WithExpression: Story = {
args: { csvInput: "1,3,5-10" },
};
/** Passing `advancedOpened` adds the Advanced switch to the header row. */
export const WithAdvancedToggle: Story = {
args: { advancedOpened: false, onToggleAdvanced: () => {} },
};
/** The switch on, as it sits while the advanced panel below is expanded. */
export const AdvancedOpened: Story = {
args: {
csvInput: "odd & 1-50",
advancedOpened: true,
onToggleAdvanced: () => {},
},
};
@@ -1,29 +0,0 @@
/**
* Lazy wrapper around the settings modal: it renders nothing until opened, so
* the heavy config bundle is only fetched when someone asks for settings.
*
* Sections come from the build's registry, and hosts can add their own or hide
* ones they cannot run.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import AppConfigModalLazy from "@app/components/shared/AppConfigModalLazy";
const meta: Meta<typeof AppConfigModalLazy> = {
title: "Shared/AppConfigModalLazy",
component: AppConfigModalLazy,
parameters: { layout: "fullscreen" },
args: { opened: false, onClose: () => {}, urlSync: false },
};
export default meta;
type Story = StoryObj<typeof AppConfigModalLazy>;
/** Closed, which is the state it spends nearly all its life in. */
export const Closed: Story = {};
export const Opened: Story = { args: { opened: true } };
/** A host that cannot run a registry section drops it by key. */
export const WithHiddenSection: Story = {
args: { opened: true, hiddenSectionKeys: ["about"] as never },
};
@@ -1,33 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
/**
* The sidebar brand header. Core has no admin portal to switch to, so this is
* just the logo — builds that bundle the portal shadow this file with a version
* whose logo doubles as the editor⇄processor switcher. Both states matter here
* because the rail collapses.
*/
const meta: Meta<typeof AppSwitcher> = {
title: "Shared/AppSwitcher",
component: AppSwitcher,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof AppSwitcher>;
/** Expanded rail — mark and wordmark. */
export const Expanded: Story = {};
/** Collapsed rail — icon only. */
export const Collapsed: Story = { args: { collapsed: true } };
/** Both, to compare the mark's optical size between the two rail widths. */
export const BothStates: Story = {
render: () => (
<div style={{ display: "flex", gap: "3rem", alignItems: "center" }}>
<AppSwitcher />
<AppSwitcher collapsed />
</div>
),
};
@@ -1,66 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import Badge from "@app/components/shared/Badge";
/** Small inline label. `colored` takes an explicit palette so callers can tint
* a badge to whatever the surrounding feature already uses. */
const meta: Meta<typeof Badge> = {
title: "Shared/Badge",
component: Badge,
parameters: { layout: "centered" },
args: { children: "Beta" },
};
export default meta;
type Story = StoryObj<typeof Badge>;
/** Default tone. */
export const Default: Story = {};
/** The three sizes together, so their baselines can be compared. */
export const Sizes: Story = {
render: () => (
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
<Badge size="sm">Small</Badge>
<Badge size="md">Medium</Badge>
<Badge size="lg">Large</Badge>
</div>
),
};
/** Explicitly tinted. The pairing is the caller's to get right — these use the
* semantic tokens rather than raw hues so they hold up in both themes. */
export const Colored: Story = {
render: () => (
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
<Badge
variant="colored"
backgroundColor="var(--c-success-solid)"
textColor="var(--c-text-on-primary)"
>
Active
</Badge>
<Badge
variant="colored"
backgroundColor="var(--c-warning-solid)"
textColor="var(--c-text-on-primary)"
>
Pending
</Badge>
<Badge
variant="colored"
backgroundColor="var(--c-danger-solid)"
textColor="var(--c-text-on-primary)"
>
Failed
</Badge>
</div>
),
};
/** A long label, to check it stays on one line rather than breaking the row. */
export const LongLabel: Story = {
args: { children: "Requires the AI engine" },
};
/** A numeral, the other common use. */
export const Count: Story = { args: { children: "12", size: "sm" } };

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