From 5b5e9220697a3d80cadde04df5e4ec4e5fac79cf Mon Sep 17 00:00:00 2001
From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:57:15 +0000
Subject: [PATCH 01/27] Make the upgrade banner neutral instead of gradient
purple (#7696)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What
The `promo` banner tone was a full-bleed `indigo-500 → purple-500`
gradient with white text and a black drop-shadow on the CTA. It was the
only saturated fill in the app, and against the warm neutral palette it
read as a foreign object above the workbench.
The bar is now app chrome:
| | Before | After |
|---|---|---|
| Background | 135° indigo→purple gradient | `--c-bg-raised` |
| Border | `transparent` | `--c-border-subtle` hairline |
| Icon | white glyph, no container | neutral glyph in a
`--c-surface-sunken` chip |
| Text | forced white | `--c-text` / `--c-text-muted` |
| CTA | `premium` accent (violet gradient) | `default` accent (same
primary button as the rest of the app) |
Before
After
Only caller is the friendly variant of `UpgradeBanner` (self-hosted,
under the free-tier user limit).
## Notes
- **No new theme tokens.** Every value is an existing `--c-*` semantic
token, so light and dark both follow automatically with no per-theme
overrides.
- The `premium` accent itself is untouched, so the upgrade CTAs in
`OfflineActivationCard` and `PairingPanel` are unaffected.
- `--c-hue-indigo` / `--c-hue-purple` are still used by
`SaaSOnboardingSlides`, `PaygFree` and `UpgradeModal`, so no tokens are
orphaned.
- Deleted comments describe rules that no longer exist (the gradient,
the white-on-gradient text overrides, the CTA shadow). No new comments
added.
## Verification
- `task frontend:check:all` passes (typecheck, oxlint, all four theme
linters, stylelint, format, tests, build, storybook build).
- `task frontend:storybook:a11y:changed` passes light and dark: 7
AppBanner stories, 0 violations. Both a11y baselines are empty, so this
is zero known violations rather than a baselined pass.
- Checked in Storybook under **Shared / AppBanner → All Top Bars**,
which renders every top bar the app can show side by side, in both
themes.
---
.../src/core/components/shared/AppBanner.css | 31 ++++++++-----------
.../src/core/components/shared/AppBanner.tsx | 2 +-
2 files changed, 14 insertions(+), 19 deletions(-)
diff --git a/frontend/editor/src/core/components/shared/AppBanner.css b/frontend/editor/src/core/components/shared/AppBanner.css
index b3f3f9343a..8151fe916a 100644
--- a/frontend/editor/src/core/components/shared/AppBanner.css
+++ b/frontend/editor/src/core/components/shared/AppBanner.css
@@ -24,17 +24,10 @@
--app-banner-icon: var(--c-accent-fg, var(--c-primary));
}
-/* The one bar meant to pop, so it takes the feature gradient rather than a tint.
- Fixed hues by design — it doesn't follow the chosen accent. */
.app-banner--promo {
- --app-banner-bg: linear-gradient(
- 135deg,
- var(--c-hue-indigo) 0%,
- var(--c-hue-purple) 100%
- );
- --app-banner-border: transparent;
- --app-banner-icon: var(--color-text-on-accent);
- color: var(--color-text-on-accent);
+ --app-banner-bg: var(--c-bg-raised);
+ --app-banner-border: var(--c-border-subtle);
+ --app-banner-icon: var(--c-text-muted);
}
.app-banner--warning {
@@ -86,16 +79,18 @@
font-size: 0.75rem;
}
-/* On the gradient everything is white; muted grey would disappear. */
-.app-banner--promo .app-banner__message,
-.app-banner--promo .app-banner__actions .sui-btn--tertiary,
-.app-banner--promo .app-banner__actions .sui-ai {
- color: var(--color-text-on-accent);
+.app-banner--promo .app-banner__icon {
+ width: 1.75rem;
+ height: 1.75rem;
+ justify-content: center;
+ border-radius: var(--radius-md);
+ background: var(--c-surface-sunken);
+ box-shadow: inset 0 0 0 1px var(--c-border-subtle);
}
-/* Lifts the premium CTA off the gradient it sits on. */
-.app-banner--promo .app-banner__actions .sui-btn--primary {
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
+.app-banner--promo.app-banner--compact .app-banner__icon {
+ width: 1.5rem;
+ height: 1.5rem;
}
.app-banner__actions {
diff --git a/frontend/editor/src/core/components/shared/AppBanner.tsx b/frontend/editor/src/core/components/shared/AppBanner.tsx
index ee0ba03741..79ff268dd2 100644
--- a/frontend/editor/src/core/components/shared/AppBanner.tsx
+++ b/frontend/editor/src/core/components/shared/AppBanner.tsx
@@ -11,7 +11,7 @@ export type AppBannerTone = "info" | "promo" | "warning" | "danger";
/** Tone decides the button too, so the CTA can't drift from the bar it sits on. */
const TONE_BUTTON = {
info: { variant: "secondary", accent: "default" },
- promo: { variant: "primary", accent: "premium" },
+ promo: { variant: "primary", accent: "default" },
warning: { variant: "primary", accent: "warning" },
danger: { variant: "primary", accent: "danger" },
} as const;
From cbe3ef8f69240eb4ddce063b3f85c9fafe7a9bda Mon Sep 17 00:00:00 2001
From: Ludy
Date: Thu, 27 Aug 2026 16:59:58 +0000
Subject: [PATCH 02/27] fix: validate frontend dependency installation (#7625)
# Description of Changes
The current check only verifies the existence of the `node_modules`
directory. After an incomplete or corrupted installation, this can lead
to the task being incorrectly marked as complete.
`npm ls --depth=0` instead checks whether the direct frontend
dependencies are actually installed and consistent. This reliably
detects and automatically repairs corrupted installations.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---
.github/labeler-config-srvaroa.yml | 1 +
.taskfiles/frontend.yml | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml
index bd1947d649..ea9613ea20 100644
--- a/.github/labeler-config-srvaroa.yml
+++ b/.github/labeler-config-srvaroa.yml
@@ -67,6 +67,7 @@ labels:
- 'frontend/**'
- 'frontend/.*'
- 'frontend/**/.*'
+ - '.taskfiles/frontend.yml'
- label: 'Tauri'
files:
diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml
index 7867315037..9727538961 100644
--- a/.taskfiles/frontend.yml
+++ b/.taskfiles/frontend.yml
@@ -23,7 +23,7 @@ tasks:
- package-lock.json
- package.json
status:
- - test -d node_modules
+ - npm ls --depth=0
env:
CI: '{{ .CI | default "false" }}'
From 4e46ba3b5a98cd4dd73ef4b898ba78d7536a0678 Mon Sep 17 00:00:00 2001
From: Ludy
Date: Thu, 27 Aug 2026 17:16:24 +0000
Subject: [PATCH 03/27] chore: prevent duplicate Dependabot Gradle PRs (#7657)
## Description of Changes
- Removed overlapping Gradle subdirectory entries from
.github/dependabot.yml.
- Dependabot now monitors the root Gradle project through /.
- Prevents duplicate pull requests for dependencies declared in Gradle
subprojects.
Closes: Not applicable
---
## Checklist
### General
- [ ] I have read the Contribution Guidelines
- [ ] I have read the Stirling-PDF Developer Guide (if applicable)
- [x] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant documentation (if applicable)
- [ ] I have read the translation tag documentation (for new translation
tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos are attached
### Testing (if applicable)
- [ ] I have tested my changes locally
---
.github/dependabot.yml | 5 -----
1 file changed, 5 deletions(-)
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 883c4f7f46..4ed61e37ec 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -8,11 +8,6 @@ updates:
- package-ecosystem: "gradle" # See documentation for possible values
directories:
- "/" # Location of package manifests
- - "/app/common"
- - "/app/core"
- - "/app/proprietary"
- - "/app/saas"
- - "/buildSrc"
schedule:
interval: "weekly"
cooldown:
From 97c0ccf58214fe502c0f93ac859d264dc58c3c00 Mon Sep 17 00:00:00 2001
From: brios <127139797+balazs-szucs@users.noreply.github.com>
Date: Thu, 27 Aug 2026 19:31:54 +0200
Subject: [PATCH 04/27] refactor(deps): optimize dependency footprints, and add
lazy initialization with platform-specific JPDFium bundling (#7620)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
app/common/build.gradle | 39 +++++++++++++++----
app/core/build.gradle | 16 +++++++-
.../api/security/RedactController.java | 4 ++
.../api/security/TextRedactionService.java | 4 ++
build.gradle | 10 ++++-
5 files changed, 62 insertions(+), 11 deletions(-)
diff --git a/app/common/build.gradle b/app/common/build.gradle
index 8af68bcb76..8ac1dfe0a9 100644
--- a/app/common/build.gradle
+++ b/app/common/build.gradle
@@ -3,6 +3,10 @@ bootRun {
enabled = false
}
dependencies {
+ // Security-hardening utilities (zip-slip, SSRF, filename sanitization, command injection).
+ // Declared as api here so core + proprietary (which depend on common) get it transitively,
+ // keeping it off modules that don't need it (e.g. saas).
+ api 'io.github.pixee:java-security-toolkit:1.2.3'
api "com.google.guava:guava:${guavaVersion}"
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aspectj'
@@ -22,7 +26,10 @@ dependencies {
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:9.3.2'
- api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support
+ // MSG file support; exclude commons-math3 (only HSSF/formula needs it, MSG parsing doesn't)
+ api('org.simplejavamail:outlook-module:9.3.2') {
+ exclude group: 'org.apache.commons', module: 'commons-math3'
+ }
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
@@ -36,12 +43,30 @@ dependencies {
api "com.stirling:jpdfium:${jpdfiumVersion}"
- // -PjpdfiumPlatforms=all|none|
- // 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform).
- def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
- def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
+ // -PjpdfiumPlatforms=auto|all|none| (windows-arm64 natives not published yet)
+ def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'auto').toString().trim()
+ def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
def jpdfiumPlatforms
- if (jpdfiumPlatformsProp == 'all') {
+ if (jpdfiumPlatformsProp == 'auto') {
+ def osName = System.getProperty('os.name').toLowerCase()
+ def osArch = System.getProperty('os.arch').toLowerCase()
+ def isArm64 = osArch.contains('aarch64') || osArch.contains('arm64')
+ if (osName.contains('linux')) {
+ jpdfiumPlatforms = isArm64 ? ['linux-arm64'] : ['linux-x64']
+ } else if (osName.contains('mac')) {
+ jpdfiumPlatforms = isArm64 ? ['darwin-arm64'] : ['darwin-x64']
+ } else if (osName.contains('win')) {
+ if (isArm64) {
+ logger.lifecycle("JPDFium natives are not available for windows-arm64; set -PjpdfiumPlatforms=none to skip bundling natives.")
+ jpdfiumPlatforms = []
+ } else {
+ jpdfiumPlatforms = ['windows-x64']
+ }
+ } else {
+ // Fallback: bundle all platforms when host can't be determined
+ jpdfiumPlatforms = jpdfiumAllPlatforms
+ }
+ } else if (jpdfiumPlatformsProp == 'all') {
jpdfiumPlatforms = jpdfiumAllPlatforms
} else if (jpdfiumPlatformsProp == 'none') {
jpdfiumPlatforms = []
@@ -51,7 +76,7 @@ dependencies {
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
if (jpdfiumInvalid) {
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
- "Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.")
+ "Valid: ${jpdfiumAllPlatforms.join(', ')}, 'auto', 'all' or 'none'.")
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}")
jpdfiumPlatforms.each { platform ->
diff --git a/app/core/build.gradle b/app/core/build.gradle
index 76828445a3..0e1533b6e8 100644
--- a/app/core/build.gradle
+++ b/app/core/build.gradle
@@ -62,8 +62,16 @@ dependencies {
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.5'
- implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
- implementation 'org.apache.poi:poi-ooxml:5.5.1'
+ // OpenCSV: Stirling-PDF only uses CSVWriter, not the opencsv-bean module.
+ // Exclude commons-beanutils + commons-collections.
+ implementation('com.opencsv:opencsv:5.12.0') {
+ exclude group: 'commons-beanutils', module: 'commons-beanutils'
+ exclude group: 'commons-collections', module: 'commons-collections'
+ }
+ // POI: only XSSF (modern Excel) is used, not HSSF/FormulaEvaluator which need commons-math3.
+ implementation('org.apache.poi:poi-ooxml:5.5.1') {
+ exclude group: 'org.apache.commons', module: 'commons-math3'
+ }
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
@@ -129,6 +137,10 @@ bootJar {
exclude 'META-INF/*.RSA'
exclude 'META-INF/*.EC'
+ // Exclude source maps from production JAR, dev-only debugging artifacts, not needed at runtime
+ exclude 'static/pdfjs-legacy/**/*.map'
+ exclude 'static/**/*.map'
+
manifest {
attributes(
'Implementation-Title': 'Stirling-PDF',
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java
index 7a186235cd..1c694da2b0 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java
@@ -221,6 +221,10 @@ public class RedactController {
.normalizeFonts(false)
.fixToUnicode(false)
.glyphAware(true)
+ .ligatureAware(true)
+ .bidiAware(true)
+ .graphemeSafe(true)
+ .sanitizeStructure(false) // WIP/Experimental API
.redactMetadata(true)
.build();
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java
index 9cf4e6c700..c0b74f5428 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java
@@ -110,6 +110,10 @@ class TextRedactionService {
.fixToUnicode(false)
.repairWidths(false)
.glyphAware(true)
+ .ligatureAware(true)
+ .bidiAware(true)
+ .graphemeSafe(true)
+ .sanitizeStructure(false)
.build();
try (PdfDocument checkDoc = PdfDocument.open(tempIn.toPath())) {
diff --git a/build.gradle b/build.gradle
index 6382e75c12..ffaba65377 100644
--- a/build.gradle
+++ b/build.gradle
@@ -42,7 +42,7 @@ ext {
bucket4jVersion = "8.19.0"
archunitVersion = "1.4.2"
batikVersion = "1.19"
- jpdfiumVersion = "1.0.4"
+ jpdfiumVersion = "1.1.3"
jwtVersion = "0.13.0"
awsSdkVersion = "2.51.3"
jschVersion = "2.28.6"
@@ -265,7 +265,6 @@ subprojects {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
- implementation 'io.github.pixee:java-security-toolkit:1.2.3'
//tmp for security bumps
implementation "ch.qos.logback:logback-core:$logback"
@@ -543,6 +542,13 @@ subprojects {
}
}
+ // Lazy initialization defers bean creation until first use,
+ // reducing dev-mode RSS significantly (heap drops ~40-60%).
+ // Enable with: ./gradlew bootRun -PlazyInit=true
+ if (rootProject.findProperty('lazyInit') == 'true') {
+ runtimeArgs.add("-Dspring.main.lazy-initialization=true")
+ logger.lifecycle("Lazy initialization enabled (-PlazyInit=true)")
+ }
jvmArgs = runtimeArgs
}
}
From 51835a7b5e8ac2f058aa7cee4e29906c94823afc Mon Sep 17 00:00:00 2001
From: jayakrishna <71440165+NGU-152002@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:04:43 +0530
Subject: [PATCH 05/27] fix: clean up Add Stamp image preview blob URLs (#6779)
Co-authored-by: James Brunton
---
.../core/components/tools/addStamp/StampSetupSettings.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx
index 4c7ee96c5b..25e2bc2243 100644
--- a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx
+++ b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx
@@ -19,6 +19,7 @@ import { AddStampParameters } from "@app/components/tools/addStamp/useAddStampPa
import ButtonSelector from "@app/components/shared/ButtonSelector";
import styles from "@app/components/tools/addStamp/StampPreview.module.css";
import { getDefaultFontSizeForAlphabet } from "@app/components/tools/addStamp/StampPreviewUtils";
+import { useFileWithUrl } from "@app/hooks/useFileWithUrl";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
const STAMP_TEMPLATES = [
@@ -209,6 +210,9 @@ const StampSetupSettings = ({
filename,
}: StampSetupSettingsProps) => {
const { t } = useTranslation();
+ const stampImageWithUrl = useFileWithUrl(
+ parameters.stampType === "image" ? (parameters.stampImage ?? null) : null,
+ );
return (
@@ -679,10 +683,10 @@ const StampSetupSettings = ({
>
{t("chooseFile", "Choose File")}
- {parameters.stampImage && (
+ {parameters.stampImage && stampImageWithUrl && (
From d4b1862654b4ad92414dc0a0ce25f8370bb459b5 Mon Sep 17 00:00:00 2001
From: Andrei Blaj <7049755+andreiblaj@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:40:26 +0300
Subject: [PATCH 06/27] feat(ocr): add rotatePages option for automatic page
orientation correction (#6697)
Co-authored-by: Andrei Blaj
Signed-off-by: Andrei Blaj
---
.../software/SPDF/controller/api/misc/OCRController.java | 7 +++++++
.../SPDF/model/api/misc/ProcessPdfWithOcrRequest.java | 5 +++++
2 files changed, 12 insertions(+)
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java
index 4803184a33..e10b86a866 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java
@@ -114,6 +114,7 @@ public class OCRController {
List selectedLanguages = request.getLanguages();
boolean sidecar = request.isSidecar();
Boolean deskew = request.isDeskew();
+ Boolean rotatePages = request.isRotatePages();
Boolean clean = request.isClean();
Boolean cleanFinal = request.isCleanFinal();
String ocrType = request.getOcrType();
@@ -154,6 +155,7 @@ public class OCRController {
selectedLanguages,
sidecar,
deskew,
+ rotatePages,
clean,
cleanFinal,
ocrType,
@@ -236,6 +238,7 @@ public class OCRController {
List selectedLanguages,
Boolean sidecar,
Boolean deskew,
+ Boolean rotatePages,
Boolean clean,
Boolean cleanFinal,
String ocrType,
@@ -268,6 +271,10 @@ public class OCRController {
if (deskew != null && deskew) {
command.add("--deskew");
}
+ if (rotatePages != null && rotatePages) {
+ // Tesseract OSD-based automatic page orientation correction (90/180/270)
+ command.add("--rotate-pages");
+ }
if (clean != null && clean) {
command.add("--clean");
}
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java
index 2955d7160f..daa6930412 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequest.java
@@ -25,6 +25,11 @@ public class ProcessPdfWithOcrRequest extends PDFFile {
@Schema(description = "Deskew the input file if set to true")
private boolean deskew;
+ @Schema(
+ description =
+ "Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true")
+ private boolean rotatePages;
+
@Schema(description = "Clean the input file if set to true")
private boolean clean;
From be130282098c01d15591fc20af3532c136c11172 Mon Sep 17 00:00:00 2001
From: Ludy
Date: Thu, 27 Aug 2026 19:41:40 +0200
Subject: [PATCH 07/27] chore(logging): enable gzipped log rotation and adjust
test logging (#7648)
---
app/core/.gitignore | 1 +
app/core/src/main/resources/logback.xml | 10 ++++++----
build.gradle | 2 +-
3 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/app/core/.gitignore b/app/core/.gitignore
index 7d9dd62931..c207c6c09a 100644
--- a/app/core/.gitignore
+++ b/app/core/.gitignore
@@ -106,6 +106,7 @@ SwaggerDoc.json
# Log file
*.log
+*.log.gz
# BlueJ files
*.ctxt
diff --git a/app/core/src/main/resources/logback.xml b/app/core/src/main/resources/logback.xml
index c0779735ae..ebdeeda64b 100644
--- a/app/core/src/main/resources/logback.xml
+++ b/app/core/src/main/resources/logback.xml
@@ -16,8 +16,9 @@
%d %p %c{1} [%thread] %m%n
- ${LOG_PATH}/auth-%d{yyyy-MM-dd}.log
- 1
+ ${LOG_PATH}/auth-%d{yyyy-MM-dd}.log.gz
+ 7
+ 64MB
@@ -28,8 +29,9 @@
%d %p %c{1} [%thread] %m%n
- ${LOG_PATH}/info-%d{yyyy-MM-dd}.log
- 1
+ ${LOG_PATH}/info-%d{yyyy-MM-dd}.log.gz
+ 7
+ 256MB
diff --git a/build.gradle b/build.gradle
index ffaba65377..fdc59f3680 100644
--- a/build.gradle
+++ b/build.gradle
@@ -306,7 +306,7 @@ subprojects {
systemProperty 'apple.awt.UIElement', 'true'
testLogging {
- events "started", "failed"
+ events "skipped", "failed"
showExceptions = true
showCauses = true
showStackTraces = true
From f71b0247dafba7d266dcc3d0f8e4dcffdfbbbda3 Mon Sep 17 00:00:00 2001
From: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:15:39 +0100
Subject: [PATCH 08/27] Quick access bar and old school sidebars (#7695)
---
.../public/locales/en-US/translation.toml | 19 +-
frontend/editor/src/core/App.tsx | 28 +--
.../fileManager/FileSourceButtons.tsx | 2 +-
.../components/filesPage/FolderTreePanel.tsx | 2 +-
.../filesPage/filesPageReturnRoute.ts | 2 +-
.../src/core/components/layout/AppFrame.css | 18 ++
.../src/core/components/layout/AppFrame.tsx | 22 ++
.../core/components/layout/NoAppChrome.tsx | 8 +
.../components/layout/Workbench.module.css | 6 +-
.../src/core/components/layout/Workbench.tsx | 30 ++-
.../core/components/layout/WorkspaceFrame.css | 16 ++
.../notifications/NotificationBell.css | 8 +-
.../notifications/NotificationBell.tsx | 119 ++---------
.../notifications/NotificationPanel.tsx | 135 ++++++++++++
.../src/core/components/shared/AppSwitch.tsx | 7 +-
.../core/components/shared/AppSwitcher.tsx | 22 --
.../core/components/shared/BrandSwitcher.css | 15 --
.../shared/BrandSwitcher.stories.tsx | 16 --
.../core/components/shared/BrandSwitcher.tsx | 57 -----
.../src/core/components/shared/BrandTile.tsx | 29 +++
.../core/components/shared/FileSidebar.css | 25 ++-
.../core/components/shared/FileSidebar.tsx | 60 +++---
.../core/components/shared/SidebarHeader.tsx | 33 +++
.../components/shared/SidebarToggleButton.tsx | 36 ++++
.../src/core/components/shared/Tooltip.tsx | 47 ++--
.../core/components/shared/WorkbenchBar.css | 22 +-
.../core/components/shared/WorkbenchBar.tsx | 20 +-
.../components/shared/navFooter/NavFooter.tsx | 87 ++++----
.../shared/quickNav/QuickNavBrand.tsx | 28 +++
.../shared/quickNav/QuickNavHostBridge.tsx | 85 ++++++++
.../shared/quickNav/QuickNavRail.css | 173 +++++++++++++++
.../shared/quickNav/QuickNavRailAccount.css | 29 +++
.../shared/quickNav/QuickNavRailAccount.tsx | 45 ++++
.../shared/quickNav/QuickNavRailBase.test.tsx | 123 +++++++++++
.../shared/quickNav/QuickNavRailBase.tsx | 101 +++++++++
.../shared/quickNav/QuickNavRailContainer.css | 38 ++++
.../shared/quickNav/QuickNavRailContainer.tsx | 83 ++++++++
.../shared/quickNav/QuickNavRailHost.tsx | 178 ++++++++++++++++
.../QuickNavRailNotifications.test.tsx | 88 ++++++++
.../quickNav/QuickNavRailNotifications.tsx | 51 +++++
.../quickNav/useQuickNavToolReasons.test.tsx | 139 ++++++++++++
.../shared/quickNav/useQuickNavToolReasons.ts | 131 ++++++++++++
.../core/components/tools/RightSidebar.tsx | 7 +-
.../src/core/components/tools/ToolPanel.css | 8 +-
.../viewer/useViewerWorkbenchBarButtons.tsx | 55 ++---
.../contexts/QuickNavHostContext.test.tsx | 128 +++++++++++
.../src/core/contexts/QuickNavHostContext.tsx | 201 ++++++++++++++++++
.../src/core/contexts/SidebarContext.tsx | 5 +
.../src/core/contexts/ToolWorkflowContext.tsx | 2 +-
frontend/editor/src/core/pages/HomePage.tsx | 191 ++++++++++++++---
frontend/editor/src/core/routes/hasPortal.ts | 2 +
.../live/viewer-sidebar-add-buttons.spec.ts | 3 +-
.../viewer-sidebar-add-buttons.spec.ts | 3 +-
.../stubbed/workbench-session-restore.spec.ts | 10 +-
frontend/editor/src/core/theme/colors.css | 2 +-
frontend/editor/src/core/theme/dimensions.css | 7 +-
frontend/editor/src/core/ui/NavSurface.tsx | 4 +-
.../src/core/utils/homePageNavigation.ts | 9 +-
.../src/core/utils/pendingReaderMode.ts | 13 ++
.../src/core/utils/viewTransition.test.ts | 63 ++++++
.../editor/src/core/utils/viewTransition.ts | 17 +-
.../desktop/components/shared/AppSwitcher.tsx | 18 --
.../editor/src/desktop/routes/hasPortal.ts | 2 +
.../editor/src/portal/components/AppShell.tsx | 22 +-
.../portal/components/EditorStatusCard.tsx | 26 +--
.../src/portal/components/PortalSearchBar.css | 3 +-
.../editor/src/portal/components/Sidebar.css | 105 +++++----
.../editor/src/portal/components/Sidebar.tsx | 47 +---
frontend/editor/src/proprietary/App.tsx | 80 ++++---
.../components/shared/AppSwitcher.tsx | 35 ---
.../proprietary/data/processorEntitySearch.ts | 10 +-
.../proprietary/data/processorSearchIndex.ts | 10 +-
.../editor/src/proprietary/routes/Landing.tsx | 4 +
.../routes/adminRouteExtensions.tsx | 8 +-
.../src/proprietary/routes/hasPortal.ts | 3 +
frontend/editor/src/saas/App.tsx | 97 +++++----
76 files changed, 2665 insertions(+), 718 deletions(-)
create mode 100644 frontend/editor/src/core/components/layout/AppFrame.css
create mode 100644 frontend/editor/src/core/components/layout/AppFrame.tsx
create mode 100644 frontend/editor/src/core/components/layout/NoAppChrome.tsx
create mode 100644 frontend/editor/src/core/components/layout/WorkspaceFrame.css
create mode 100644 frontend/editor/src/core/components/notifications/NotificationPanel.tsx
delete mode 100644 frontend/editor/src/core/components/shared/AppSwitcher.tsx
delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.css
delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx
delete mode 100644 frontend/editor/src/core/components/shared/BrandSwitcher.tsx
create mode 100644 frontend/editor/src/core/components/shared/BrandTile.tsx
create mode 100644 frontend/editor/src/core/components/shared/SidebarHeader.tsx
create mode 100644 frontend/editor/src/core/components/shared/SidebarToggleButton.tsx
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavBrand.tsx
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.test.tsx
create mode 100644 frontend/editor/src/core/components/shared/quickNav/QuickNavRailNotifications.tsx
create mode 100644 frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.test.tsx
create mode 100644 frontend/editor/src/core/components/shared/quickNav/useQuickNavToolReasons.ts
create mode 100644 frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx
create mode 100644 frontend/editor/src/core/contexts/QuickNavHostContext.tsx
create mode 100644 frontend/editor/src/core/routes/hasPortal.ts
create mode 100644 frontend/editor/src/core/utils/pendingReaderMode.ts
create mode 100644 frontend/editor/src/core/utils/viewTransition.test.ts
delete mode 100644 frontend/editor/src/desktop/components/shared/AppSwitcher.tsx
create mode 100644 frontend/editor/src/desktop/routes/hasPortal.ts
delete mode 100644 frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx
create mode 100644 frontend/editor/src/proprietary/routes/hasPortal.ts
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 8271d66dfc..378157818c 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -4141,7 +4141,6 @@ mobileShort = "Mobile"
mobileUpload = "Mobile Upload"
mobileUploadNotAvailable = "Mobile upload not enabled"
moreOptions = "More options"
-myFiles = "My Files"
nextFile = "Next file"
noFiles = "No files available"
noFilesFound = "No files found matching your search"
@@ -4242,9 +4241,9 @@ duplicateFailed = "Could not duplicate file"
expand = "Expand sidebar"
googleDrive = "Google Drive"
googleDriveDisabled = "Google Drive is not configured"
-leaveMyFiles = "Leave My Files"
+leaveMyFiles = "Leave File library"
library = "PDF Library"
-myFiles = "My Files"
+myFiles = "File library"
noFiles = "No files yet"
openFileManager = "Browse all files & folders"
openFromComputer = "Open from computer"
@@ -4287,7 +4286,7 @@ addToWorkspaceCount = "Add {{count}} to workspace"
allFiles = "All files"
back = "Back"
backToFolder = "Back to {{folder}}"
-backToMyFiles = "Back to My Files"
+backToMyFiles = "Back to File library"
breadcrumbs = "Folder path"
bulkActions = "Actions"
cancel = "Cancel"
@@ -4339,7 +4338,6 @@ localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to
moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)."
moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)."
moveTo = "Move to…"
-myFiles = "My Files"
newFolder = "New folder"
newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on."
newFolderTabUnavailable = "Switch to All or Cloud to create folders."
@@ -9173,7 +9171,6 @@ appEditor = "Editor"
appProcessor = "Processor"
linkAccount = "Link Stirling account"
primaryNav = "Primary navigation"
-switchApp = "Switch app"
[portal.shell.topbar]
closeNav = "Close navigation"
@@ -9652,6 +9649,16 @@ automate = "Automate"
config = "Config"
files = "Files"
+[quickNav]
+editor = "Editor"
+home = "Stirling"
+invite = "Invite"
+landmark = "Quick navigation"
+noProcessorAccess = "Ask an admin for processor access"
+notifications = "Notifications"
+processor = "Processor"
+reader = "Reader"
+
[read]
tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse"
diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx
index 81db0564b8..c51c1fb85d 100644
--- a/frontend/editor/src/core/App.tsx
+++ b/frontend/editor/src/core/App.tsx
@@ -1,6 +1,7 @@
import { Suspense, lazy } from "react";
import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
+import { AppFrame } from "@app/components/layout/AppFrame";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { ThemeProvider } from "@app/components/shared/ThemeProvider";
@@ -53,18 +54,21 @@ export default function App() {
}
/>
- {/* All other routes need AppProviders for backend integration */}
-
-
-
-
-
-
- }
- />
+ {/* The app, under a shared frame so the rail renders once outside it. */}
+ }>
+ {/* All other routes need AppProviders for backend integration */}
+
+
+
+
+
+
+ }
+ />
+
);
diff --git a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx
index 6ac3e3c91e..20ebaa191e 100644
--- a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx
+++ b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx
@@ -173,7 +173,7 @@ const FileSourceButtons: React.FC = ({
mb="xs"
style={{ paddingLeft: "1rem" }}
>
- {t("fileManager.myFiles", "My Files")}
+ {t("fileSidebar.myFiles", "File library")}
{buttons}
diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx
index eb7f19170c..b56915b757 100644
--- a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx
+++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx
@@ -121,7 +121,7 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {
- {t("filesPage.myFiles", "My Files")}
+ {t("fileSidebar.myFiles", "File library")}
diff --git a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts
index 5e6292d62f..69a1bc1c53 100644
--- a/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts
+++ b/frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts
@@ -1,6 +1,6 @@
/**
* Stores the route the user came from when they open files into the
- * workbench from My Files. Lets the workbench show a "Back to My Files"
+ * workbench from the file library. Lets the workbench show a "Back to File library"
* affordance and return to the exact folder they were browsing.
*
* Persisted in sessionStorage so a hard reload keeps the return path
diff --git a/frontend/editor/src/core/components/layout/AppFrame.css b/frontend/editor/src/core/components/layout/AppFrame.css
new file mode 100644
index 0000000000..0cc20b30c6
--- /dev/null
+++ b/frontend/editor/src/core/components/layout/AppFrame.css
@@ -0,0 +1,18 @@
+/* ========== APP FRAME ========== */
+/* The rail's column, then whichever app is mounted, so a switch changes only the app. */
+.app-frame {
+ display: flex;
+ height: 100vh;
+ height: 100dvh; /* track mobile browser chrome */
+ overflow: hidden;
+ background-color: var(--c-bg);
+}
+
+/* min-width: 0 so the app shrinks instead of forcing the frame past the window. */
+.app-frame__content {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+}
+
+/* The rail hides itself below the mobile breakpoint - see QuickNavRailContainer.css. */
diff --git a/frontend/editor/src/core/components/layout/AppFrame.tsx b/frontend/editor/src/core/components/layout/AppFrame.tsx
new file mode 100644
index 0000000000..38fa92ecba
--- /dev/null
+++ b/frontend/editor/src/core/components/layout/AppFrame.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from "react";
+import { Outlet } from "react-router-dom";
+import { LoadingFallback } from "@app/components/shared/LoadingFallback";
+import { QuickNavHostProvider } from "@app/contexts/QuickNavHostContext";
+import { QuickNavRailHost } from "@app/components/shared/quickNav/QuickNavRailHost";
+import "@app/components/layout/AppFrame.css";
+
+/** The rail renders once outside both apps; Suspense sits inside it, not above. */
+export function AppFrame() {
+ return (
+
+
+
+
+ }>
+
+
+
+
+
+ );
+}
diff --git a/frontend/editor/src/core/components/layout/NoAppChrome.tsx b/frontend/editor/src/core/components/layout/NoAppChrome.tsx
new file mode 100644
index 0000000000..04416c02f1
--- /dev/null
+++ b/frontend/editor/src/core/components/layout/NoAppChrome.tsx
@@ -0,0 +1,8 @@
+import { Outlet } from "react-router-dom";
+import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext";
+
+/** Pages that aren't the app: inside the frame for its providers, but with no rail. */
+export function NoAppChrome() {
+ useSuppressQuickNavRail();
+ return ;
+}
diff --git a/frontend/editor/src/core/components/layout/Workbench.module.css b/frontend/editor/src/core/components/layout/Workbench.module.css
index dd2b4a12bd..22d6fdc43c 100644
--- a/frontend/editor/src/core/components/layout/Workbench.module.css
+++ b/frontend/editor/src/core/components/layout/Workbench.module.css
@@ -12,10 +12,8 @@
.workbenchBarReopenTab {
position: absolute;
top: 100%;
- /* Right-align with the retract handle inside the bar: the bar's right
- margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own
- 6px inset. */
- right: calc(var(--nav-gutter) + 15px);
+ /* Aligns with the retract handle: 8px bar padding plus its own 6px inset. */
+ right: 14px;
display: flex;
align-items: center;
justify-content: center;
diff --git a/frontend/editor/src/core/components/layout/Workbench.tsx b/frontend/editor/src/core/components/layout/Workbench.tsx
index 903c552fd2..0cac20e574 100644
--- a/frontend/editor/src/core/components/layout/Workbench.tsx
+++ b/frontend/editor/src/core/components/layout/Workbench.tsx
@@ -1,4 +1,4 @@
-import { useState, Suspense, lazy } from "react";
+import { useState, useEffect, useRef, Suspense, lazy } from "react";
import { useTranslation } from "react-i18next";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { Box, Loader, Center, Stack, Text } from "@mantine/core";
@@ -15,6 +15,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useSigningOverlay } from "@app/contexts/SigningOverlayContext";
import { useCookieConsent } from "@app/hooks/useCookieConsent";
+import { useIsPhone } from "@app/hooks/useIsMobile";
import styles from "@app/components/layout/Workbench.module.css";
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
@@ -58,10 +59,13 @@ export default function Workbench() {
setPageEditorFunctions,
setSidebarsVisible,
customWorkbenchViews,
+ readerMode,
} = useToolWorkflow();
const { handleToolSelect } = useToolWorkflow();
const { overlay: signingOverlay } = useSigningOverlay();
+ // Below this width the rail, and the bell it carries, is gone.
+ const isPhone = useIsPhone();
// Get navigation state - this is the source of truth
const { selectedTool: selectedToolId } = useNavigationState();
@@ -92,8 +96,20 @@ export default function Workbench() {
!isBaseWorkbench(currentView) ||
// Shared signing drives the viewer from the sidebar with no file in context.
(currentView === "viewer" && !!signingOverlay?.file);
- const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent;
- const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent;
+ // Reading hides the bar; the rail's Reader entry is the way back.
+ const showWorkbenchBar =
+ topControlsAvailable && hasWorkbenchContent && !readerMode;
+ const showFloatingSearch =
+ topControlsAvailable && !hasWorkbenchContent && !readerMode;
+
+ // On the transition, so reading sets the toolbar's start state without locking it.
+ const prevReaderModeRef = useRef(readerMode);
+ useEffect(() => {
+ if (readerMode !== prevReaderModeRef.current) {
+ setViewerToolbarCollapsed(readerMode);
+ prevReaderModeRef.current = readerMode;
+ }
+ }, [readerMode]);
const handlePreviewClose = () => {
setPreviewFile(null);
@@ -126,7 +142,7 @@ export default function Workbench() {
}
}
- // The "My Files" workbench is available regardless of whether files are
+ // The file-library workbench is available regardless of whether files are
// currently loaded into the workbench - it lives on top of the IDB store.
if (currentView === "myFiles") {
return ;
@@ -249,10 +265,8 @@ export default function Workbench() {
data-tour="workbench"
style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }}
>
- {/* The bell normally rides in the workbench bar. Wherever that bar is not shown - My Files,
- an empty workbench, a custom view without top controls - it gets its own corner, rather
- than those being the places a user cannot see that something of theirs failed. */}
- {!showWorkbenchBar && (
+ {/* Phone only: above that the rail carries the bell, and here no bar does. */}
+ {isPhone && !showWorkbenchBar && (
diff --git a/frontend/editor/src/core/components/layout/WorkspaceFrame.css b/frontend/editor/src/core/components/layout/WorkspaceFrame.css
new file mode 100644
index 0000000000..05cabe95f9
--- /dev/null
+++ b/frontend/editor/src/core/components/layout/WorkspaceFrame.css
@@ -0,0 +1,16 @@
+/* ========== WORKSPACE FRAME ========== */
+/* Rail and sidebar side by side, full height. Shared by both apps. */
+.workspace-frame {
+ display: flex;
+ height: 100%;
+ flex-shrink: 0;
+ background-color: var(--c-bg);
+}
+
+/* On mobile the sidebar is a fixed drawer, so the frame stops laying out. */
+@media (max-width: 48rem) {
+ .workspace-frame {
+ display: block;
+ height: auto;
+ }
+}
diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.css b/frontend/editor/src/core/components/notifications/NotificationBell.css
index 9352f6bef8..46367a0b2b 100644
--- a/frontend/editor/src/core/components/notifications/NotificationBell.css
+++ b/frontend/editor/src/core/components/notifications/NotificationBell.css
@@ -10,7 +10,7 @@
position: relative;
padding: var(--sp-2, 0.5rem);
border: none;
- border-radius: var(--radius-md, 0.375rem);
+ border-radius: var(--radius-md);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
@@ -48,6 +48,12 @@
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
}
+/* The rail's bell is at the foot of a full-height column, so its panel rises beside it. */
+.notification-bell__panel--rail {
+ inset-inline-start: calc(var(--nav-rail-w) + var(--nav-gutter));
+ inset-block-end: var(--nav-gutter);
+}
+
.notification-bell__heading {
margin: 0 0 var(--sp-2, 0.5rem);
font-size: 0.875rem;
diff --git a/frontend/editor/src/core/components/notifications/NotificationBell.tsx b/frontend/editor/src/core/components/notifications/NotificationBell.tsx
index f2ac1e0a3a..def06eed8d 100644
--- a/frontend/editor/src/core/components/notifications/NotificationBell.tsx
+++ b/frontend/editor/src/core/components/notifications/NotificationBell.tsx
@@ -1,27 +1,15 @@
-import {
- Fragment,
- useEffect,
- useId,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
+import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BellIcon, Button } from "@app/ui";
-import DividerWithText from "@app/components/shared/DividerWithText";
import { useNotifications } from "@app/hooks/useNotifications";
import { useNotificationActions } from "@app/components/notifications/notificationActions";
-import { NotificationItem } from "@app/components/notifications/NotificationItem";
+import { NotificationPanel } from "@app/components/notifications/NotificationPanel";
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
import "@app/components/notifications/NotificationBell.css";
-/**
- * Renders whatever the server sends without knowing which subsystem produced it or what its actions
- * mean, so a new source or failure kind needs no change here. In core because both shells mount it.
- */
+/** For the narrow layouts where the rail, which carries the bell, is off screen. */
export function NotificationBell() {
- // A build with no notifications API gets no bell at all, rather than one that polls a
- // nonexistent endpoint forever to show nothing.
+ // No API means no bell at all, rather than one polling an endpoint that isn't there.
const available = useNotificationsAvailable();
if (!available) return null;
return ;
@@ -29,14 +17,10 @@ export function NotificationBell() {
function MountedNotificationBell() {
const { t } = useTranslation();
- const { notifications, unreadCount, documentStateFor, markAllSeen } =
- useNotifications();
+ const { unreadCount } = useNotifications();
const registry = useNotificationActions();
const [open, setOpen] = useState(false);
const container = useRef(null);
- const headingId = useId();
- // Where the new ones stop, frozen when the panel opens (opening marks everything read).
- const [firstSeenId, setFirstSeenId] = useState(null);
// Viewport-fixed, because the workbench bar clips its own overflow.
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(
null,
@@ -61,54 +45,17 @@ function MountedNotificationBell() {
};
}, [open]);
- // Opening marks them read, not closing: waiting would leave the badge lit while they read.
- const toggle = () => {
- setOpen((wasOpen) => {
- if (!wasOpen) {
- // Before marking, or there is nothing left to read.
- setFirstSeenId(notifications[unreadCount]?.id ?? null);
- markAllSeen();
- }
- return !wasOpen;
- });
- };
-
- /**
- * How many count as new. No boundary id means all of them were; one that has since left the list
- * leaves nothing to divide on, so it reads as none rather than guessing at a row.
- */
- const boundaryIndex = firstSeenId
- ? notifications.findIndex((notification) => notification.id === firstSeenId)
- : notifications.length;
- const dividedAt = Math.max(0, boundaryIndex);
-
- useEffect(() => {
- if (!open) return;
- const closeOnOutside = (event: MouseEvent) => {
- const target = event.target as HTMLElement;
- if (!container.current?.contains(target)) setOpen(false);
- };
- const closeOnEscape = (event: KeyboardEvent) => {
- if (event.key === "Escape") setOpen(false);
- };
- document.addEventListener("mousedown", closeOnOutside);
- document.addEventListener("keydown", closeOnEscape);
- return () => {
- document.removeEventListener("mousedown", closeOnOutside);
- document.removeEventListener("keydown", closeOnEscape);
- };
- }, [open]);
-
return (
- )}
- {/* Only with something on both sides: a lone "Earlier" over everything says
- nothing the empty badge has not. */}
- {index === dividedAt && dividedAt > 0 && (
-
-
-
- )}
- setOpen(false)}
- />
-
- ))}
-
- )}
-
+ />
)}
);
diff --git a/frontend/editor/src/core/components/notifications/NotificationPanel.tsx b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx
new file mode 100644
index 0000000000..f3ff0c621e
--- /dev/null
+++ b/frontend/editor/src/core/components/notifications/NotificationPanel.tsx
@@ -0,0 +1,135 @@
+import { Fragment, useEffect, useId, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import DividerWithText from "@app/components/shared/DividerWithText";
+import { useNotifications } from "@app/hooks/useNotifications";
+import type { ClientActionRegistry } from "@app/components/notifications/notificationActions";
+import { NotificationItem } from "@app/components/notifications/NotificationItem";
+import "@app/components/notifications/NotificationBell.css";
+
+/** Named so a trigger in another tree can point at it with aria-controls. */
+export const NOTIFICATIONS_PANEL_ID = "quick-nav-notifications-panel";
+
+export interface NotificationPanelProps {
+ onClose: () => void;
+ id?: string;
+ /** Passed in: its document handover has to run whether the panel is open or not. */
+ registry: ClientActionRegistry;
+ style?: React.CSSProperties;
+ className?: string;
+}
+
+/** Mounted only while open, since mounting is what marks everything read. */
+export function NotificationPanel({
+ onClose,
+ registry,
+ id,
+ style,
+ className,
+}: NotificationPanelProps) {
+ const { t } = useTranslation();
+ const { notifications, unreadCount, documentStateFor, markAllSeen } =
+ useNotifications();
+ const panel = useRef(null);
+ const headingId = useId();
+ // Frozen on open, since opening marks them all read.
+ const [firstSeenId, setFirstSeenId] = useState(null);
+
+ // On mount, not on close: waiting leaves the badge lit while they read.
+ const marked = useRef(false);
+ useEffect(() => {
+ if (marked.current) return;
+ marked.current = true;
+ // Before marking, or there is nothing left to divide on.
+ setFirstSeenId(notifications[unreadCount]?.id ?? null);
+ markAllSeen();
+ }, [notifications, unreadCount, markAllSeen]);
+
+ // No boundary means all were new; one that has left the list means none.
+ const boundaryIndex = firstSeenId
+ ? notifications.findIndex((notification) => notification.id === firstSeenId)
+ : notifications.length;
+ const dividedAt = Math.max(0, boundaryIndex);
+
+ // Focus goes back to the opener only if it is still inside the panel on close.
+ useEffect(() => {
+ const opener = document.activeElement as HTMLElement | null;
+ panel.current?.focus();
+ return () => {
+ if (panel.current?.contains(document.activeElement)) opener?.focus();
+ };
+ }, []);
+
+ useEffect(() => {
+ const closeOnOutside = (event: MouseEvent) => {
+ const target = event.target as HTMLElement;
+ if (panel.current?.contains(target)) return;
+ // A trigger closes this itself; counting it as outside would reopen it.
+ if (target.closest?.("[data-notifications-trigger]")) return;
+ onClose();
+ };
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ };
+ document.addEventListener("mousedown", closeOnOutside);
+ document.addEventListener("keydown", closeOnEscape);
+ return () => {
+ document.removeEventListener("mousedown", closeOnOutside);
+ document.removeEventListener("keydown", closeOnEscape);
+ };
+ }, [onClose]);
+
+ return (
+
+
+ {t("notifications.title", "Notifications")}
+
+
+ {notifications.length === 0 ? (
+
+ {t("notifications.empty", "Nothing to report.")}
+
+ )}
+ {/* Only with unread rows above it. */}
+ {index === dividedAt && dividedAt > 0 && (
+
+
+
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx
index 71d35ed5ad..fbbe85ccdb 100644
--- a/frontend/editor/src/core/components/shared/AppSwitch.tsx
+++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx
@@ -11,12 +11,7 @@ interface AppSwitchMenuItemsProps {
onSwitch: (app: AppSwitchTarget) => void;
}
-/**
- * The editor / processor items for the app-switch menu. Rendered inside the
- * BrandSwitcher's logo dropdown, which both apps use as their switcher. The
- * mark is the shared , which recolours itself from the theme
- * tokens, so no colour-scheme prop needs threading down here.
- */
+/** The editor / processor items for an app-switch menu. */
export function AppSwitchMenuItems({
current,
onSwitch,
diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx
deleted file mode 100644
index aaf55148ef..0000000000
--- a/frontend/editor/src/core/components/shared/AppSwitcher.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Logo } from "@app/ui/Logo";
-
-export interface AppSwitcherProps {
- /** Icon-only brand mark for the collapsed rail. */
- collapsed?: boolean;
-}
-
-/**
- * Sidebar brand header. Core has no admin portal to switch to, so it just
- * shows the Stirling logo. Builds that bundle the portal (proprietary/saas)
- * shadow this with a version whose logo doubles as the editor⇄processor
- * switcher.
- */
-export function AppSwitcher({ collapsed }: AppSwitcherProps) {
- return (
-
- );
-}
diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.css b/frontend/editor/src/core/components/shared/BrandSwitcher.css
deleted file mode 100644
index dc1659ea15..0000000000
--- a/frontend/editor/src/core/components/shared/BrandSwitcher.css
+++ /dev/null
@@ -1,15 +0,0 @@
-/* Logo + app-switch dropdown, shared between the editor and the processor.
- The logo itself is the trigger (its mark morphs into a chevron on hover). */
-.sui-brand-switcher {
- display: flex;
- align-items: center;
- flex: 1;
- min-width: 0;
-}
-
-/* Tighten the ghost-button padding so the lockup sits flush like a plain logo,
- and negative-margin it back so the hover surface still extends past the text. */
-.sui-brand-switcher__trigger.sui-btn {
- --button-padding-x: 0.375rem;
- margin-inline: -0.375rem;
-}
diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx
deleted file mode 100644
index 92deb518b2..0000000000
--- a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react";
-import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
-
-const meta: Meta = {
- title: "Brand/BrandSwitcher",
- component: BrandSwitcher,
- parameters: { layout: "centered" },
- args: { current: "processor", onSwitch: () => {} },
- argTypes: {
- current: { control: "inline-radio", options: ["editor", "processor"] },
- },
-};
-export default meta;
-type Story = StoryObj;
-
-export const Playground: Story = {};
diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx
deleted file mode 100644
index 474173fee2..0000000000
--- a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { useState } from "react";
-import { useTranslation } from "react-i18next";
-import { Button, Dropdown } from "@app/ui";
-import { Logo } from "@app/ui/Logo";
-import { BrandMark } from "@app/components/shared/BrandMark";
-import {
- AppSwitchMenuItems,
- type AppSwitchTarget,
-} from "@app/components/shared/AppSwitch";
-import "@app/components/shared/BrandSwitcher.css";
-
-interface BrandSwitcherProps {
- /** The app this is rendered in (shown active in the menu). */
- current: AppSwitchTarget;
- /** Called with the selected app (only for the non-current one). */
- onSwitch: (app: AppSwitchTarget) => void;
- /** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */
- collapsed?: boolean;
- className?: string;
-}
-
-/**
- * Brand lockup that doubles as the editor⇄processor switcher. The whole logo
- * is the dropdown trigger: on hover / focus / open the mark morphs into a
- * downward chevron (see BrandMark), so no separate chevron button is needed.
- * Shared so the editor and the processor present one identical header.
- */
-export function BrandSwitcher({
- current,
- onSwitch,
- collapsed = false,
- className,
-}: BrandSwitcherProps) {
- const { t } = useTranslation();
- const [open, setOpen] = useState(false);
-
- return (
-
-
-
- }
- >
- {!collapsed && }
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/editor/src/core/components/shared/BrandTile.tsx b/frontend/editor/src/core/components/shared/BrandTile.tsx
new file mode 100644
index 0000000000..e8ccba6db8
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/BrandTile.tsx
@@ -0,0 +1,29 @@
+interface BrandTileProps {
+ /** CSS length. Omit to let the caller's CSS size it. */
+ size?: string;
+ className?: string;
+}
+
+/** The mark in a rounded square. Decorative: call sites carry the accessible name. */
+export function BrandTile({ size, className }: BrandTileProps) {
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css
index 2347d9a2b2..76db9deab4 100644
--- a/frontend/editor/src/core/components/shared/FileSidebar.css
+++ b/frontend/editor/src/core/components/shared/FileSidebar.css
@@ -1,7 +1,9 @@
/* ========== FILE SIDEBAR ========== */
.file-sidebar {
- background-color: var(--c-bg);
+ /* One solid panel, with a rule only on the workbench side, so it frames the document. */
+ background-color: var(--c-surface);
+ border-inline-end: 1px solid var(--c-border-subtle);
display: flex;
flex-direction: column;
height: 100%;
@@ -37,12 +39,19 @@
gap: 0.5rem;
}
-/* ---- Brand header (logo / editor⇄processor switcher) ---- */
-.file-sidebar-brand {
+/* Flattened here; two classes deep to beat .sui-nav-surface without relying on order. */
+.file-sidebar .sui-nav-surface {
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+}
+
+/* ---- Header row (wordmark + collapse toggle) ---- */
+.file-sidebar-header {
display: flex;
align-items: center;
- min-height: 40px;
- padding: 0 0.375rem;
+ min-height: var(--nav-header-h);
+ padding: 0 var(--nav-gutter);
flex-shrink: 0;
}
@@ -51,9 +60,9 @@
flex-shrink: 0;
}
-.file-sidebar[data-collapsed="true"] .file-sidebar-brand {
- flex-direction: column;
- gap: 0.25rem;
+/* Collapsed the row holds only the toggle, so centre it. */
+.file-sidebar[data-collapsed="true"] .file-sidebar-header {
+ justify-content: center;
padding: 0;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle {
diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx
index f9337bd880..1dcf4a7301 100644
--- a/frontend/editor/src/core/components/shared/FileSidebar.tsx
+++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx
@@ -24,7 +24,6 @@ import { useViewer } from "@app/contexts/ViewerContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
-import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
import { useOpenPlan } from "@app/hooks/useOpenPlan";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
import {
@@ -32,8 +31,7 @@ import {
useIndexedDBRevision,
} from "@app/contexts/IndexedDBContext";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
-import { AppSwitcher } from "@app/components/shared/AppSwitcher";
-import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
+import { SidebarHeader } from "@app/components/shared/SidebarHeader";
import type { StirlingFileStub } from "@app/types/fileContext";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
@@ -78,8 +76,9 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import "@app/components/shared/FileSidebar.css";
-const COLLAPSED_WIDTH = "3.5rem";
-const EXPANDED_WIDTH = "16.25rem"; // ~260px
+// Shared with the processor sidebar via tokens, so the two cannot drift.
+const COLLAPSED_WIDTH = "var(--sidebar-collapsed-w)";
+const EXPANDED_WIDTH = "var(--sidebar-w)";
// Inlined to avoid a circular import with WatchedFoldersRegistration.
const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
@@ -98,9 +97,11 @@ export interface FileSidebarProps {
collapsed?: boolean;
onToggleCollapse?: () => void;
onOpenSettings?: () => void;
- /** Accessible name override for the toggle button. */
+ /** The quick nav rail owns the account control, so the footer drops its own row. */
+ accountHoisted?: boolean;
+ /** Accessible name override for the collapse toggle. */
toggleAriaLabel?: string;
- /** Icon override for the toggle button (e.g. back-arrow on /files). */
+ /** Icon override for the collapse toggle (e.g. back-arrow on /files). */
toggleIcon?: React.ReactNode;
/** Override the Open-from-computer handler (e.g. upload to /files folder). */
onUploadFiles?: (files: File[]) => void | Promise;
@@ -155,11 +156,12 @@ const FileSidebar = forwardRef(
collapsed = false,
onToggleCollapse,
onOpenSettings,
+ accountHoisted = false,
+ toggleAriaLabel,
+ toggleIcon,
onUploadFiles,
onPickGoogleDriveFiles,
extraAction,
- toggleAriaLabel,
- toggleIcon,
},
ref,
) {
@@ -249,7 +251,6 @@ const FileSidebar = forwardRef(
const { displayName, profilePictureUrl, isAnonymous } =
useAccountIdentity();
const credits = useFreeCreditsSummary();
- const otherApp = useOtherAppSwitch();
const openPlan = useOpenPlan();
// Leaf files = user-visible files (excludes intermediate tool outputs)
@@ -943,25 +944,12 @@ const FileSidebar = forwardRef(
+
{/* Box 1 — top controls (open / my files / cloud). No title. File
search lives in the global super search (top bar), not here. */}
@@ -984,7 +972,7 @@ const FileSidebar = forwardRef(
{/* Tooltips only fire when collapsed - when expanded the visible
text label below already identifies each row, so a tooltip
would just flash a duplicate. Distinct icons (UploadFile for
- "Open from computer" vs FolderOpen for "My Files") so the
+ "Open from computer" vs FolderOpen for "File library") so the
collapsed rail isn't two identical folder icons either. */}
(
onClick={() => {
// "Open from computer" goes straight to the native OS file
// picker. The full file manager (recent + drives + folders)
- // is reachable via "My Files" below.
+ // is reachable via "File library" below.
nativeFileInputRef.current?.click();
}}
role="button"
@@ -1080,7 +1068,7 @@ const FileSidebar = forwardRef(
)}
(
}}
role="button"
tabIndex={0}
- aria-label={t("fileSidebar.myFiles", "My Files")}
+ aria-label={t("fileSidebar.myFiles", "File library")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@@ -1105,7 +1093,7 @@ const FileSidebar = forwardRef(
{!collapsed && (
- {t("fileSidebar.myFiles", "My Files")}
+ {t("fileSidebar.myFiles", "File library")}
)}
@@ -1370,15 +1358,15 @@ const FileSidebar = forwardRef(
{/* Getting-started checklist, floating above the footer (SaaS only). */}
- {/* Box 3 — the shared footer: credits, app switch, account row. */}
+ {/* Box 3 — the shared footer: credits, plan, and the account row unless hoisted. */}
diff --git a/frontend/editor/src/core/components/shared/SidebarHeader.tsx b/frontend/editor/src/core/components/shared/SidebarHeader.tsx
new file mode 100644
index 0000000000..1a2befa074
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/SidebarHeader.tsx
@@ -0,0 +1,33 @@
+import { Logo } from "@app/ui/Logo";
+import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton";
+
+export interface SidebarHeaderProps {
+ collapsed?: boolean;
+ onToggleCollapse?: () => void;
+ toggleAriaLabel?: string;
+ toggleIcon?: React.ReactNode;
+ className?: string;
+}
+
+/** The wordmark and the collapse toggle; the brand mark sits in the rail beside it. */
+export function SidebarHeader({
+ collapsed,
+ onToggleCollapse,
+ toggleAriaLabel,
+ toggleIcon,
+ className,
+}: SidebarHeaderProps) {
+ return (
+
- )}
+ {header && }
- {/* Left: optional "Back to My Files" + view switcher */}
+ {/* Left: optional "Back to File library" + view switcher */}
{returnRoute && hasFiles && (
<>
@@ -501,7 +503,7 @@ export default function WorkbenchBar({
: "filesPage.backToMyFiles",
returnRoute.label
? `Back to ${returnRoute.label}`
- : "Back to My Files",
+ : "Back to File library",
{ folder: returnRoute.label ?? "" },
)}
leftSection={}
@@ -511,7 +513,7 @@ export default function WorkbenchBar({
? t("filesPage.backToFolder", "Back to {{folder}}", {
folder: returnRoute.label,
})
- : t("filesPage.backToMyFiles", "Back to My Files")}
+ : t("filesPage.backToMyFiles", "Back to File library")}
@@ -603,9 +605,13 @@ export default function WorkbenchBar({
enforcingProgress={enforcingProgress}
/>
)}
- {/* Last in the globals, so it is the rightmost control. */}
-
-
+ {isPhone && (
+ <>
+ {/* Last in the globals, so it is the rightmost control. */}
+
+
+ >
+ )}
);
diff --git a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx
index 373c91bccf..848c0cb4a5 100644
--- a/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx
+++ b/frontend/editor/src/core/components/shared/navFooter/NavFooter.tsx
@@ -33,6 +33,8 @@ export interface NavFooterProps {
otherApp?: NavFooterAppLink | null;
/** Extra rows above the account row (the self-hosted link-account CTA). */
accountExtras?: ReactNode;
+ /** False where the rail owns the account control, so only one avatar is drawn. */
+ showAccount?: boolean;
/** Icon-rail state: labels collapse to tooltips. */
collapsed?: boolean;
className?: string;
@@ -69,6 +71,7 @@ export function NavFooter({
onOpenPlan,
otherApp,
accountExtras,
+ showAccount = true,
collapsed = false,
className,
}: NavFooterProps) {
@@ -144,49 +147,51 @@ export function NavFooter({
});
}
- rows.push({
- key: "account",
- node: (
-
-
+
+ ),
+ });
+ }
+
+ if (rows.length === 0) return null;
return (
void;
+}
+
+export function QuickNavBrand({ onReturnHome }: QuickNavBrandProps) {
+ const { t } = useTranslation();
+ const label = t("quickNav.home", "Stirling");
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx
new file mode 100644
index 0000000000..9a9101a826
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx
@@ -0,0 +1,85 @@
+import { useCallback, useMemo, useState } from "react";
+import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
+import {
+ NotificationPanel,
+ NOTIFICATIONS_PANEL_ID,
+} from "@app/components/notifications/NotificationPanel";
+import { useNotificationActions } from "@app/components/notifications/notificationActions";
+import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons";
+import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
+import { useSigningBadgeCount } from "@app/hooks/signing/useSigningBadgeCount";
+import {
+ useRegisterQuickNavHost,
+ type QuickNavToolReasons,
+} from "@app/contexts/QuickNavHostContext";
+import type { ToolId } from "@app/types/toolId";
+
+export interface QuickNavHostBridgeProps {
+ portalAccess?: boolean;
+ readerMode?: boolean;
+ onSetReaderMode?: (on: boolean) => void;
+ onOpenSettings: () => void;
+ requestNavigation?: (go: () => void) => void;
+ onGoToDefaultState?: () => void;
+ onSelectTool?: (toolId: ToolId) => void;
+ /** Merged over the reasons worked out here, for what only the app can see. */
+ toolReasons?: QuickNavToolReasons;
+}
+
+/** Registers with the rail what only the app can see, and owns the notifications panel. */
+export function QuickNavHostBridge({
+ portalAccess = false,
+ readerMode = false,
+ onSetReaderMode,
+ onOpenSettings,
+ requestNavigation,
+ onSelectTool,
+ onGoToDefaultState,
+ toolReasons,
+}: QuickNavHostBridgeProps) {
+ const { displayName, profilePictureUrl } = useAccountIdentity();
+ const signingBadge = useSigningBadgeCount();
+ const notificationsAvailable = useNotificationsAvailable();
+ // Built even when closed: it carries a one-shot document pickup that would sit unclaimed.
+ const notificationActions = useNotificationActions();
+ const endpointReasons = useQuickNavToolReasons();
+ const mergedToolReasons = useMemo(() => {
+ // An empty map from the app is silence, not an answer.
+ const extra =
+ toolReasons && Object.keys(toolReasons).length > 0 ? toolReasons : null;
+ if (!endpointReasons && !extra) return undefined;
+ return { ...endpointReasons, ...extra };
+ }, [endpointReasons, toolReasons]);
+ const [notificationsOpen, setNotificationsOpen] = useState(false);
+ const closeNotifications = useCallback(() => setNotificationsOpen(false), []);
+
+ useRegisterQuickNavHost(
+ {
+ identity: { displayName, profilePictureUrl },
+ signingBadge,
+ portalAccess,
+ readerMode,
+ notificationsOpen,
+ toolReasons: mergedToolReasons,
+ },
+ {
+ openSettings: onOpenSettings,
+ requestNavigation,
+ selectTool: onSelectTool,
+ setReaderMode: onSetReaderMode,
+ goToDefaultState: onGoToDefaultState,
+ toggleNotifications: () => setNotificationsOpen((open) => !open),
+ },
+ );
+
+ // Mounted only while open, so a closed panel never subscribes to the poll.
+ if (!notificationsAvailable || !notificationsOpen) return null;
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css
new file mode 100644
index 0000000000..4d6845f592
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRail.css
@@ -0,0 +1,173 @@
+/* ========== QUICK NAV RAIL ========== */
+
+.quick-nav-rail {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ height: 100%;
+ width: 100%;
+}
+
+.quick-nav-rail-group {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--quicknav-item-gap);
+ flex-shrink: 0;
+ width: 100%;
+}
+
+/* The glyph stays at the sidebar's scale; the button fills the rail for hit area. */
+.quick-nav-rail-item {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 2.25rem;
+ padding: 0;
+ border: none;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--c-text-subtle);
+ cursor: pointer;
+ transition:
+ background-color var(--motion-fast),
+ color var(--motion-fast);
+}
+
+.quick-nav-rail-item svg,
+.quick-nav-rail-item img {
+ width: 1.125rem;
+ height: 1.125rem;
+ color: inherit;
+ fill: currentColor;
+}
+
+/* Taller than wide (71x79), so height drives the width. */
+.quick-nav-rail-item .sui-brandmark {
+ width: auto;
+ height: 1.125rem;
+}
+
+.quick-nav-rail-item[aria-disabled="true"] .sui-brandmark,
+.quick-nav-rail-item[aria-disabled="true"] svg[viewBox="0 0 256 256"] {
+ filter: grayscale(1);
+}
+
+.quick-nav-rail-item:hover {
+ background: var(--c-hover);
+ color: var(--c-text);
+}
+
+/* Opacity rather than a colour: there is no disabled-text token. */
+.quick-nav-rail-item[aria-disabled="true"] {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.quick-nav-rail-item[aria-disabled="true"]:hover {
+ background: transparent;
+ color: var(--c-primary);
+}
+
+.quick-nav-rail-item:focus-visible {
+ outline: 0.125rem solid var(--c-primary);
+ outline-offset: -0.125rem;
+}
+
+.quick-nav-rail-badge {
+ position: absolute;
+ top: 0.125rem;
+ inset-inline-end: 0.125rem;
+ min-width: 0.875rem;
+ height: 0.875rem;
+ padding: 0 0.1875rem;
+ border-radius: var(--radius-pill);
+ /* The solid step: 9px numerals need the darker end of the ramp. */
+ background: var(--c-danger-solid);
+ color: var(--c-text-on-primary);
+ font-size: 0.5625rem;
+ font-weight: var(--font-weight-semibold);
+ line-height: 0.875rem;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+ pointer-events: none;
+}
+
+.quick-nav-rail-badge[data-tone="warning"] {
+ background: var(--c-warning-solid);
+}
+
+/* Top margin only: the group below supplies the other half, centring the rule. */
+.quick-nav-rail-divider {
+ width: 100%;
+ height: 0;
+ margin: var(--quicknav-item-gap) 0 0;
+ border: 0;
+ border-top: 1px solid var(--c-border);
+}
+
+.quick-nav-rail-footer {
+ margin-top: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--quicknav-item-gap);
+ width: 100%;
+ flex-shrink: 0;
+}
+
+/* ---- Brand: one header row tall, so it lines up with the sidebar's wordmark ---- */
+.quick-nav-brand {
+ width: 100%;
+ height: var(--nav-header-h);
+ flex-shrink: 0;
+ /* The bar's inset supplies part of the gap; only the remainder is added here. */
+ margin-bottom: calc(var(--quicknav-item-gap) - var(--quicknav-surface-pad));
+}
+
+.quick-nav-brand-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ border: none;
+ background: transparent;
+ padding: 0;
+ cursor: pointer;
+}
+
+.quick-nav-brand-button:focus-visible {
+ outline: 0.125rem solid var(--c-primary);
+ outline-offset: -0.125rem;
+ border-radius: var(--radius-md);
+}
+
+/* The "on" state: a solid block with the glyph knocked out, hover owning the tints. */
+.quick-nav-rail-item[aria-current="true"],
+.quick-nav-rail-item[aria-current="true"]:hover,
+.quick-nav-rail-item[aria-pressed="true"],
+.quick-nav-rail-item[aria-pressed="true"]:hover {
+ background: var(--c-text);
+ color: var(--c-surface);
+}
+
+/* On a dark ground full ink is white, so mix the block back toward the surface. */
+[data-theme="dark"] .quick-nav-rail-item[aria-current="true"],
+[data-theme="dark"] .quick-nav-rail-item[aria-current="true"]:hover,
+[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"],
+[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"]:hover,
+html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"],
+html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"]:hover,
+html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"],
+html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"]:hover,
+[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-current="true"],
+[data-mantine-color-scheme="dark"]
+ .quick-nav-rail-item[aria-current="true"]:hover,
+[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-pressed="true"],
+[data-mantine-color-scheme="dark"]
+ .quick-nav-rail-item[aria-pressed="true"]:hover {
+ background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface));
+ color: var(--c-surface);
+}
diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css
new file mode 100644
index 0000000000..c12a6a1f71
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.css
@@ -0,0 +1,29 @@
+/* The account control, pinned to the bottom of the bar. */
+
+.quick-nav-rail-account {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-1);
+ flex-shrink: 0;
+ /* Further than the shortcut gap: a filled disc reads heavier than a line glyph. */
+ margin-top: var(--space-2);
+ /* Matches the slack centring the brand mark leaves at the top. */
+ padding-bottom: 0.3125rem;
+}
+
+.quick-nav-rail-avatar-target {
+ display: inline-flex;
+}
+
+/* Appearance comes from the shared Avatar; only the button reset is ours. */
+.quick-nav-rail-avatar {
+ border: none;
+ padding: 0;
+ user-select: none;
+}
+
+.quick-nav-rail-avatar:focus-visible {
+ outline: 0.125rem solid var(--c-primary);
+ outline-offset: 0.125rem;
+}
diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx
new file mode 100644
index 0000000000..1404925de6
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailAccount.tsx
@@ -0,0 +1,45 @@
+import { useTranslation } from "react-i18next";
+import { Tooltip } from "@app/components/shared/Tooltip";
+import { Avatar } from "@app/ui/Avatar";
+import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext";
+import "@app/components/shared/quickNav/QuickNavRailAccount.css";
+
+export interface QuickNavRailAccountProps {
+ onOpenSettings: () => void;
+ /** Null between apps; the disc still renders, so the bar keeps its shape. */
+ identity: QuickNavIdentity | null;
+}
+
+/** The avatar opens settings, so there is no separate gear beside it. */
+export function QuickNavRailAccount({
+ onOpenSettings,
+ identity,
+}: QuickNavRailAccountProps) {
+ const { t } = useTranslation();
+ const displayName =
+ identity?.displayName ?? t("auth.displayName.user", "User");
+ const profilePictureUrl = identity?.profilePictureUrl ?? null;
+ const label = `${displayName} — ${t("fileSidebar.openSettings", "Open settings")}`;
+
+ return (
+
+
+ {/* A span, not the Avatar: Tooltip binds by cloning its child. */}
+
+
+
+
+
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx
new file mode 100644
index 0000000000..328f2d8f4e
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.test.tsx
@@ -0,0 +1,123 @@
+import { describe, it, expect } from "vitest";
+import { render } from "@testing-library/react";
+import {
+ QuickNavRailBase,
+ type QuickNavEntry,
+} from "@app/components/shared/quickNav/QuickNavRailBase";
+
+/** The rail needs no providers. */
+function withProviders(ui: React.ReactNode) {
+ return <>{ui}>;
+}
+
+function entry(
+ id: string,
+ overrides: Partial = {},
+): QuickNavEntry {
+ return {
+ id,
+ label: id,
+ icon: null,
+ onClick: () => {},
+ ...overrides,
+ };
+}
+
+const PROCESSOR = entry("processor");
+const WITHIN = [entry("files"), entry("reader")];
+
+function renderRail(groups: QuickNavEntry[][]) {
+ const { container } = render(
+ withProviders(),
+ );
+ return {
+ labels: [...container.querySelectorAll(".quick-nav-rail-item")].map((b) =>
+ b.getAttribute("aria-label"),
+ ),
+ dividers: container.querySelectorAll(".quick-nav-rail-divider").length,
+ };
+}
+
+describe("QuickNavRailBase — groups", () => {
+ it("divides one group from the next", () => {
+ const { labels, dividers } = renderRail([[PROCESSOR], WITHIN]);
+
+ expect(labels).toEqual(["processor", "files", "reader"]);
+ expect(dividers).toBe(1);
+ });
+
+ it("drops an empty group, and the divider with it", () => {
+ const { labels, dividers } = renderRail([[], WITHIN]);
+
+ expect(labels).toEqual(["files", "reader"]);
+ expect(dividers).toBe(0);
+ });
+});
+
+describe("QuickNavRailBase — entry state", () => {
+ it("reports on/off for a toggle and nothing for the rest", () => {
+ // Nothing here is a view you occupy, so only a real toggle has state.
+ const { container } = render(
+ withProviders(
+ ,
+ ),
+ );
+
+ const state = [...container.querySelectorAll(".quick-nav-rail-item")].map(
+ (b) => [b.getAttribute("aria-label"), b.getAttribute("aria-pressed")],
+ );
+ expect(state).toEqual([
+ ["processor", null],
+ ["reader", "true"],
+ ["files", null],
+ ]);
+ expect(container.querySelectorAll("[aria-current]")).toHaveLength(0);
+ });
+
+ it("keeps a disabled entry in the tab order so its reason stays reachable", () => {
+ // The tooltip carrying the reason is only reachable while it can be focused.
+ const { container } = render(
+ withProviders(
+ ,
+ ),
+ );
+
+ const automate = container.querySelector('[aria-label="automate"]')!;
+ expect(automate.getAttribute("aria-disabled")).toBe("true");
+ expect(automate.hasAttribute("disabled")).toBe(false);
+ });
+
+ it("keeps an unavailable entry rendered, disabled rather than dropped", () => {
+ // Slots must not appear and vanish as access resolves.
+ const { container } = render(
+ withProviders(
+ ,
+ ),
+ );
+
+ const processor = container.querySelector('[aria-label="processor"]');
+ expect(processor).not.toBeNull();
+ expect(processor?.getAttribute("aria-disabled")).toBe("true");
+ // aria-disabled, not the disabled attribute: it stays focusable for its tooltip.
+ expect(processor?.hasAttribute("disabled")).toBe(false);
+ });
+});
diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx
new file mode 100644
index 0000000000..5c0be45db0
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailBase.tsx
@@ -0,0 +1,101 @@
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { Tooltip } from "@app/components/shared/Tooltip";
+import "@app/components/shared/quickNav/QuickNavRail.css";
+
+export type QuickNavTarget = "reader" | "editor" | "files" | "processor";
+
+export interface QuickNavEntry {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ /** The app you are in, drawn with an edge bar. */
+ current?: boolean;
+ /** Only for entries that toggle something; use `current` for the app you are in. */
+ pressed?: boolean;
+ /** Inert, with `reason` as its tooltip. Entries are dimmed, never dropped. */
+ disabled?: boolean;
+ reason?: string;
+ badge?: number;
+ /** Popup semantics for an entry whose panel is rendered in another tree. */
+ expanded?: boolean;
+ controls?: string;
+ /** "danger" waits on the user; "warning" is awareness only. */
+ badgeTone?: "danger" | "warning";
+ onClick: () => void;
+}
+
+export interface QuickNavRailBaseProps {
+ /** Divided by a rule; empty groups are dropped. */
+ groups: QuickNavEntry[][];
+ footer?: ReactNode;
+}
+
+/** Exported so footer entries reuse it rather than a lookalike. */
+export function RailButton({
+ label,
+ icon,
+ pressed,
+ disabled,
+ reason,
+ badge,
+ badgeTone = "danger",
+ current,
+ expanded,
+ controls,
+ onClick,
+}: Omit) {
+ return (
+
+
+ {icon}
+ {badge !== undefined && badge > 0 && (
+
+ {badge > 9 ? "9+" : badge}
+
+ )}
+
+
+ );
+}
+
+export function QuickNavRailBase({ groups, footer }: QuickNavRailBaseProps) {
+ const { t } = useTranslation();
+ const populated = groups.filter((entries) => entries.length > 0);
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css
new file mode 100644
index 0000000000..606c2c3831
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.css
@@ -0,0 +1,38 @@
+.quick-nav-rail-container {
+ /* On the column, so the parts outside the nav inherit them too. */
+ --quicknav-item-gap: var(--space-3);
+ --quicknav-surface-pad: 0.375rem;
+
+ width: var(--nav-rail-w);
+ height: 100%;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ /* On the column, not the bar, so the fill covers the gutters too. */
+ background-color: var(--c-surface);
+ border-inline-end: 1px solid var(--c-border-subtle);
+ display: flex;
+ flex-direction: column;
+ padding-block: var(--nav-gutter);
+ padding-inline: calc(var(--nav-gutter) / 2);
+}
+
+/* Child selector to beat .sui-nav-surface, which would win on order. */
+.quick-nav-rail-container > .quick-nav-rail-surface {
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+}
+
+.quick-nav-rail-surface {
+ flex: 1;
+ min-height: 0;
+ /* No inline padding: the bar is one target wide and would squeeze the buttons. */
+ padding: var(--quicknav-surface-pad) 0;
+}
+
+/* Below this width the sidebar is an off-canvas drawer, and the rail is just noise. */
+@media (max-width: 48rem) {
+ .quick-nav-rail-container {
+ display: none;
+ }
+}
diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx
new file mode 100644
index 0000000000..154700518e
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailContainer.tsx
@@ -0,0 +1,83 @@
+import { useTranslation } from "react-i18next";
+import { NavSurface } from "@app/ui/NavSurface";
+import LocalIcon from "@app/components/shared/LocalIcon";
+import { QuickNavBrand } from "@app/components/shared/quickNav/QuickNavBrand";
+import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext";
+import {
+ QuickNavRailBase,
+ RailButton,
+ type QuickNavRailBaseProps,
+} from "@app/components/shared/quickNav/QuickNavRailBase";
+import { QuickNavRailAccount } from "@app/components/shared/quickNav/QuickNavRailAccount";
+import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications";
+import "@app/components/shared/quickNav/QuickNavRailContainer.css";
+
+export type {
+ QuickNavEntry,
+ QuickNavTarget,
+} from "@app/components/shared/quickNav/QuickNavRailBase";
+
+export interface QuickNavRailContainerProps extends Omit<
+ QuickNavRailBaseProps,
+ "footer"
+> {
+ /** The rail owns the account control, so the sidebars drop their own row. */
+ onOpenSettings?: () => void;
+ /** Omitted in builds with no processor to invite anyone into. */
+ onInvite?: () => void;
+ onToggleNotifications?: () => void;
+ notificationsOpen?: boolean;
+ identity?: QuickNavIdentity | null;
+ onReturnHome: () => void;
+}
+
+/** The fixed-width column the rail sits in. */
+export function QuickNavRailContainer({
+ onOpenSettings,
+ onInvite,
+ onToggleNotifications,
+ notificationsOpen,
+ identity = null,
+ onReturnHome,
+ ...railProps
+}: QuickNavRailContainerProps) {
+ const { t } = useTranslation();
+ return (
+
+ {
- const next = !c;
- writePersistedSidebarCollapsed(next);
- return next;
- });
- }}
- onOpenSettings={() => setConfigModalOpen(true)}
- />
+ toggleIcon={
+ navigationState.workbench === "myFiles" ? (
+
+ ) : undefined
+ }
+ active={navigationState.workbench === "myFiles"}
+ // Forced: a deep link to /files has no transition to collapse on.
+ collapsed={
+ navigationState.workbench === "myFiles" ||
+ fileSidebarCollapsed
+ }
+ onToggleCollapse={handleSidebarToggle}
+ onOpenSettings={() => setConfigModalOpen(true)}
+ />
+
{!hideToolPanel && }
diff --git a/frontend/editor/src/core/routes/hasPortal.ts b/frontend/editor/src/core/routes/hasPortal.ts
new file mode 100644
index 0000000000..3d7f107ac3
--- /dev/null
+++ b/frontend/editor/src/core/routes/hasPortal.ts
@@ -0,0 +1,2 @@
+/** Whether this build ships the processor. Shadowed per build. */
+export const HAS_PORTAL = false;
diff --git a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts
index df3ce94b23..a2e2c9c7e3 100644
--- a/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts
+++ b/frontend/editor/src/core/tests/live/viewer-sidebar-add-buttons.spec.ts
@@ -60,7 +60,8 @@ function fixture(filename: string): string {
}
async function openSamplePdfInViewer(page: import("@playwright/test").Page) {
- await page.goto("/read");
+ // Not /read: reading collapses the workbench bar these sidebars are toggled from.
+ await page.goto("/");
await page.waitForLoadState("domcontentloaded");
await page
.locator('[data-testid="file-input"]')
diff --git a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts
index 67df3865e1..f0da928122 100644
--- a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts
@@ -23,7 +23,8 @@ const SAMPLE_PDF = path.join(
);
async function openViewerWithSample(page: import("@playwright/test").Page) {
- await page.goto("/read");
+ // Not /read: reading collapses the workbench bar these sidebars are toggled from.
+ await page.goto("/");
await page.waitForLoadState("domcontentloaded");
await page
.locator('[data-testid="file-input"]')
diff --git a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts
index 48765392fe..c1a71ac48c 100644
--- a/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/workbench-session-restore.spec.ts
@@ -27,6 +27,7 @@ async function restoreEnabled(
}
const NO_RESTORE = "this build ships the workbench restore off";
+const NO_PORTAL = "this build ships no processor to switch to";
// Switching editor -> processor unmounts every editor provider; the session record
// in sessionStorage is what brings the workbench back on return.
@@ -73,8 +74,13 @@ test.describe("Workbench survives the editor/processor switch", () => {
page.getByRole("radio", { name: /Active Files/i }),
).toBeChecked();
- // Out through the sidebar footer switch - the real user path.
- await page.getByRole("button", { name: "Open PDF Processor" }).click();
+ // Out through the rail's processor mark, the only chrome that offers the switch.
+ const processorMark = page.getByRole("button", { name: /^Processor$/i });
+ test.skip(
+ !(await processorMark.isVisible({ timeout: 5_000 }).catch(() => false)),
+ NO_PORTAL,
+ );
+ await processorMark.click();
await expect(page).toHaveURL(/\/processor/, { timeout: 15000 });
// Split the two halves of the feature: if this fails the writer is at fault,
diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css
index 31f9666271..1a74158022 100644
--- a/frontend/editor/src/core/theme/colors.css
+++ b/frontend/editor/src/core/theme/colors.css
@@ -46,7 +46,7 @@ html[data-app-theme="light"] {
non-text floor applies. Scheme-independent: a filled badge reads white on
either ground. */
--c-success-solid: var(--p-green-700);
- --c-danger-solid: var(--p-red-600);
+ --c-danger-solid: var(--p-red-700);
--c-warning-solid: var(--p-amber-700);
--c-neutral-solid: var(--p-gray-600);
--c-accent-solid: var(--p-blue-600);
diff --git a/frontend/editor/src/core/theme/dimensions.css b/frontend/editor/src/core/theme/dimensions.css
index 554baddcca..50531641cb 100644
--- a/frontend/editor/src/core/theme/dimensions.css
+++ b/frontend/editor/src/core/theme/dimensions.css
@@ -30,7 +30,12 @@
--radius-nav: 0.625rem;
--nav-gutter: 0.5rem;
- --nav-rail-w: 3.5rem;
+ /* Every minimised rail is this wide, so they line up as one column of icons. */
+ --nav-rail-w: 3rem;
+ /* Header row, so the rail's brand and a sidebar's wordmark line up. */
+ --nav-header-h: 3rem;
+ --sidebar-w: 16.25rem;
+ --sidebar-collapsed-w: var(--nav-rail-w);
/* ── Layout sizing ── */
--footer-height: 2rem;
diff --git a/frontend/editor/src/core/ui/NavSurface.tsx b/frontend/editor/src/core/ui/NavSurface.tsx
index 38947fb91f..37caaa063e 100644
--- a/frontend/editor/src/core/ui/NavSurface.tsx
+++ b/frontend/editor/src/core/ui/NavSurface.tsx
@@ -2,8 +2,8 @@ import { forwardRef, type HTMLAttributes } from "react";
import "@app/ui/NavSurface.css";
export interface NavSurfaceProps extends HTMLAttributes {
- /** Element to render; `section`/`aside` when the box is a landmark. */
- as?: "div" | "section" | "aside";
+ /** Element to render; `section`/`aside`/`nav` when the box is a landmark. */
+ as?: "div" | "section" | "aside" | "nav";
}
/**
diff --git a/frontend/editor/src/core/utils/homePageNavigation.ts b/frontend/editor/src/core/utils/homePageNavigation.ts
index 001e026710..7a91bfec65 100644
--- a/frontend/editor/src/core/utils/homePageNavigation.ts
+++ b/frontend/editor/src/core/utils/homePageNavigation.ts
@@ -1,4 +1,4 @@
-import type { WorkbenchType } from "@app/types/workbench";
+import { getDefaultWorkbench, type WorkbenchType } from "@app/types/workbench";
export type StartupWorkbench = "viewer" | "fileEditor";
@@ -7,6 +7,13 @@ export interface StartupNavigationAction {
activeFileIndex?: number;
}
+/** Several files means the file editor; one or none the viewer. */
+export function getDefaultWorkbenchForFileCount(
+ fileCount: number,
+): WorkbenchType {
+ return fileCount > 1 ? "fileEditor" : getDefaultWorkbench();
+}
+
export function getStartupNavigationAction(
previousFileCount: number,
currentFileCount: number,
diff --git a/frontend/editor/src/core/utils/pendingReaderMode.ts b/frontend/editor/src/core/utils/pendingReaderMode.ts
new file mode 100644
index 0000000000..faacfba6b8
--- /dev/null
+++ b/frontend/editor/src/core/utils/pendingReaderMode.ts
@@ -0,0 +1,13 @@
+let pending = false;
+
+/** Carries "open in reading mode" across an app switch, and deliberately not a reload. */
+export function requestReaderMode(): void {
+ pending = true;
+}
+
+/** True once per request. */
+export function consumeReaderModeRequest(): boolean {
+ if (!pending) return false;
+ pending = false;
+ return true;
+}
diff --git a/frontend/editor/src/core/utils/viewTransition.test.ts b/frontend/editor/src/core/utils/viewTransition.test.ts
new file mode 100644
index 0000000000..5fb11c8aa2
--- /dev/null
+++ b/frontend/editor/src/core/utils/viewTransition.test.ts
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { withViewTransition } from "@app/utils/viewTransition";
+
+// The stub carries only the field the helper reads, hence the cast through unknown.
+type MutableDoc = { startViewTransition?: unknown };
+const doc = document as unknown as MutableDoc;
+
+function stubApi(): ReturnType {
+ const start = vi.fn((cb: () => void) => {
+ cb();
+ return { finished: Promise.resolve() };
+ });
+ doc.startViewTransition = start;
+ return start;
+}
+
+function stubReducedMotion(reduced: boolean): void {
+ vi.stubGlobal("matchMedia", (query: string) => ({
+ matches: reduced && query.includes("prefers-reduced-motion"),
+ media: query,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }));
+}
+
+afterEach(() => {
+ delete doc.startViewTransition;
+ vi.unstubAllGlobals();
+});
+
+describe("withViewTransition", () => {
+ it("runs the update inside a transition when one is possible", async () => {
+ const start = stubApi();
+ stubReducedMotion(false);
+ const update = vi.fn();
+
+ await withViewTransition(update);
+
+ expect(start).toHaveBeenCalledTimes(1);
+ expect(update).toHaveBeenCalledTimes(1);
+ });
+
+ it("skips the transition when the user asked for less motion", async () => {
+ // The state change must still happen - only the animation is dropped.
+ const start = stubApi();
+ stubReducedMotion(true);
+ const update = vi.fn();
+
+ await withViewTransition(update);
+
+ expect(start).not.toHaveBeenCalled();
+ expect(update).toHaveBeenCalledTimes(1);
+ });
+
+ it("still applies the update where the API is unavailable", async () => {
+ stubReducedMotion(false);
+ const update = vi.fn();
+
+ await withViewTransition(update);
+
+ expect(update).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/editor/src/core/utils/viewTransition.ts b/frontend/editor/src/core/utils/viewTransition.ts
index 049c3e7673..85a05ca78d 100644
--- a/frontend/editor/src/core/utils/viewTransition.ts
+++ b/frontend/editor/src/core/utils/viewTransition.ts
@@ -4,21 +4,20 @@ type ViewTransitionDoc = Document & {
startViewTransition?: (cb: () => void) => { finished: Promise };
};
-/**
- * Run a state update inside a View Transition so the browser cross-fades
- * (and morphs any elements sharing a {@code view-transition-name}) between
- * the before/after DOMs.
- *
- * Falls back to a plain synchronous update when the API is unavailable
- * (Firefox <130, JSDOM, motion-reduced preference).
- */
+/** Runs a state update in a View Transition, plainly where that is unavailable. */
export function withViewTransition(update: () => void): Promise {
if (typeof document === "undefined") {
update();
return Promise.resolve();
}
+ // Callers don't each check: reduced motion still gets the state change.
+ const reduced =
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
const doc = document as ViewTransitionDoc;
- if (doc.startViewTransition) {
+ if (doc.startViewTransition && !reduced) {
return doc.startViewTransition(() => flushSync(update)).finished;
}
update();
diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx
deleted file mode 100644
index 21896d9a1d..0000000000
--- a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Logo } from "@app/ui/Logo";
-import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
-
-/**
- * Desktop inherits proprietary's layers but does not ship the portal (see
- * desktop/routes/adminRouteExtensions), so there's nothing to switch to —
- * shadow the brand header back to a plain logo. (Also avoids the desktop
- * bundle referencing @portal via the proprietary switcher's imports.)
- */
-export function AppSwitcher({ collapsed }: AppSwitcherProps) {
- return (
-
- );
-}
diff --git a/frontend/editor/src/desktop/routes/hasPortal.ts b/frontend/editor/src/desktop/routes/hasPortal.ts
new file mode 100644
index 0000000000..0eec40f4c9
--- /dev/null
+++ b/frontend/editor/src/desktop/routes/hasPortal.ts
@@ -0,0 +1,2 @@
+/** Desktop inherits proprietary's app but never ships the portal. */
+export const HAS_PORTAL = false;
diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx
index fe8f775fd0..ec6c720297 100644
--- a/frontend/editor/src/portal/components/AppShell.tsx
+++ b/frontend/editor/src/portal/components/AppShell.tsx
@@ -7,8 +7,11 @@ import { PortalSearchBar } from "@portal/components/PortalSearchBar";
import { useUI } from "@portal/contexts/UIContext";
import { MenuIcon, SearchIcon } from "@portal/components/icons";
import { Logo } from "@app/ui/Logo";
+import "@app/components/layout/WorkspaceFrame.css";
+import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge";
import "@portal/components/AppShell.css";
import { NotificationBell } from "@app/components/notifications/NotificationBell";
+import { useIsPhone } from "@app/hooks/useIsMobile";
/**
* Compact header shown only under the mobile breakpoint (CSS-hidden on
@@ -58,8 +61,10 @@ function MobileTopbar() {
* prop-free.
*/
export function AppShell({ children }: { children: ReactNode }) {
- const { mobileNavOpen, closeMobileNav } = useUI();
+ const { mobileNavOpen, closeMobileNav, openSettings } = useUI();
const { pathname } = useLocation();
+ // Below this width the rail, and the bell it carries, is gone.
+ const isPhone = useIsPhone();
// Navigating (tap on a nav row, back button, deep link) always dismisses the
// drawer. Depends on pathname only: the close fn's identity changes with any
@@ -79,7 +84,11 @@ export function AppShell({ children }: { children: ReactNode }) {
return (
-
+ {/* portalAccess: being here is proof the processor is available. */}
+ openSettings()} />
+
+
+
{mobileNavOpen && (
-
-
-
+ {/* Phone only: above that the rail carries it, and this would be a second. */}
+ {isPhone && (
+
+
+
+ )}
{children}
diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx
index e4918ed859..774dc4d1be 100644
--- a/frontend/editor/src/portal/components/EditorStatusCard.tsx
+++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx
@@ -11,31 +11,9 @@ import {
import { type EditorInstance } from "@portal/api/editorDeploy";
import { DownloadEditorModal } from "@portal/components/DownloadEditorModal";
import "@portal/theme/surface.css";
+import { BrandTile } from "@app/components/shared/BrandTile";
import "@portal/components/EditorStatusCard.css";
-/** The Stirling brand mark, drawn at the hero size. Decorative. */
-function StirlingMark() {
- return (
-
- );
-}
-
/** The instance to headline: the busiest healthy one, else the first. */
function primaryInstance(instances: EditorInstance[]): EditorInstance | null {
if (instances.length === 0) return null;
@@ -126,7 +104,7 @@ export function EditorStatusCard({ footer }: EditorStatusCardProps) {
>
-
+
diff --git a/frontend/editor/src/portal/components/PortalSearchBar.css b/frontend/editor/src/portal/components/PortalSearchBar.css
index 8e253b6136..847991af20 100644
--- a/frontend/editor/src/portal/components/PortalSearchBar.css
+++ b/frontend/editor/src/portal/components/PortalSearchBar.css
@@ -1,5 +1,4 @@
-/* Unpainted strip at the top of the main column. Height matches the sidebar's
- logo row (.portal-sidebar__logo, 51px) so the search lines up with the brand. */
+/* Unpainted strip at the top of the main column, matching the sidebar header's height. */
.portal-searchbar {
display: flex;
align-items: center;
diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css
index 48b54b177d..43f8c44695 100644
--- a/frontend/editor/src/portal/components/Sidebar.css
+++ b/frontend/editor/src/portal/components/Sidebar.css
@@ -1,8 +1,10 @@
.portal-sidebar {
- width: 15rem;
+ width: var(--sidebar-w);
height: 100vh;
height: 100dvh; /* track mobile browser chrome */
- background: var(--c-bg);
+ /* Matches the editor's sidebar: one solid panel with a rule on the content side. */
+ background: var(--c-surface);
+ border-inline-end: 1px solid var(--c-border-subtle);
display: flex;
flex-direction: column;
flex-shrink: 0;
@@ -21,10 +23,6 @@
/* Nav labels stay on one line and are clipped by the narrowing rail so they
reveal/hide cleanly as the width animates rather than wrapping. */
-.portal-sidebar__nav,
-.portal-sidebar__footer {
- overflow-x: hidden;
-}
.portal-sidebar .sui-navitem__label,
.portal-sidebar__section-label {
white-space: nowrap;
@@ -34,6 +32,8 @@
.portal-sidebar__close {
display: none;
flex-shrink: 0;
+ /* Trailing edge: on mobile this is the row's only control. */
+ margin-left: auto;
}
.portal-sidebar__collapse {
@@ -78,23 +78,16 @@
/* ---- Collapsed icon rail (desktop only) ---- */
.portal-sidebar[data-collapsed] {
- width: var(--nav-rail-w);
-}
-.portal-sidebar[data-collapsed] .portal-sidebar__logo {
- flex-direction: column;
- height: auto;
- padding: 0.5rem 0;
- gap: 0.375rem;
-}
-.portal-sidebar[data-collapsed] .portal-sidebar__collapse {
- margin-left: 0;
+ width: var(--sidebar-collapsed-w);
}
+/* Flush, like the nav: the selected row runs the full width of the rail. */
.portal-sidebar[data-collapsed] .portal-sidebar__nav {
- padding-inline: 0.375rem;
+ padding-inline: 0;
}
+/* Stretch, not centre: a centred group shrinks to its content, so rows can't fill the rail. */
.portal-sidebar[data-collapsed] .portal-sidebar__section {
padding-inline: 0;
- align-items: center;
+ align-items: stretch;
}
.portal-sidebar[data-collapsed] .portal-sidebar__section-label {
display: none;
@@ -111,23 +104,16 @@
margin-inline: 0;
padding-inline: 0;
width: 100%;
-}
-/* Neutralise the active-item edge-bar geometry (negative margins + overhang)
- that assumes the full-width rail. */
-.portal-sidebar[data-collapsed] .sui-navitem.is-active {
- width: 100%;
- margin-inline: 0;
- border-left: none;
- border-radius: 0.5rem;
- padding-left: 0;
+ /* A square target, so it takes the rail's radius rather than NavItem's pill. */
+ border-radius: var(--radius-md);
}
.portal-sidebar[data-collapsed] .portal-sidebar__footer {
margin-inline: 0.375rem;
}
-.portal-sidebar__logo {
- height: 3.1875rem; /* 51px */
- padding: 0 0.875rem;
+.portal-sidebar__header {
+ height: var(--nav-header-h);
+ padding: 0 var(--nav-gutter);
display: flex;
align-items: center;
gap: 0.5rem;
@@ -136,35 +122,58 @@
.portal-sidebar__nav {
flex: 0 1 auto;
overflow-y: auto;
- padding: 0.75rem 0.625rem;
+ overflow-x: clip;
+ /* No inline inset above the rows, so a row is full width and needs no bleed past the clip. */
+ padding: var(--nav-gutter) 0;
display: flex;
flex-direction: column;
- gap: 0.5rem;
+ gap: var(--nav-gutter);
}
.portal-sidebar .sui-navitem {
- margin-inline: 0.25rem;
- padding-inline: 0.625rem;
+ margin-inline: 0;
+ padding-inline: 1.75rem;
}
-.portal-sidebar .sui-navitem.is-active {
- width: calc(100% + 0.75rem);
- margin-inline: -0.375rem;
+/* The selected view, marked as the rail marks the current app: a knocked-out solid block. */
+.portal-sidebar .sui-navitem.is-active,
+.portal-sidebar .sui-navitem.is-active:hover {
+ background: var(--c-text);
+ color: var(--c-surface);
border-radius: 0;
- border-left: 3px solid var(--c-primary);
- padding-left: calc(1.25rem - 3px);
+}
+
+/* On a dark ground full ink is white, so mix the block back toward the surface. */
+[data-theme="dark"] .portal-sidebar .sui-navitem.is-active,
+[data-theme="dark"] .portal-sidebar .sui-navitem.is-active:hover,
+html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active,
+html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active:hover,
+[data-mantine-color-scheme="dark"] .portal-sidebar .sui-navitem.is-active,
+[data-mantine-color-scheme="dark"]
+ .portal-sidebar
+ .sui-navitem.is-active:hover {
+ background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface));
+ color: var(--c-surface);
}
.portal-sidebar__section {
- padding: 0.5rem 0.375rem 0.375rem;
+ padding: 0.5rem 0 0.375rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
+/* Flattened here; two classes deep to beat .sui-nav-surface regardless of load order. */
+.portal-sidebar .sui-nav-surface {
+ background: transparent;
+ border: 0;
+ border-radius: 0;
+}
+
.portal-sidebar__section-label {
margin: 0;
- padding: 0 0.5rem;
+ /* Its own 0.5rem, plus the inset the nav and section no longer add. */
+ padding: 0 1.375rem;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.02em;
@@ -181,4 +190,18 @@
only positions it. */
.portal-sidebar__footer {
margin: 0 0.625rem 0.75rem;
+ overflow-x: hidden;
+}
+
+/* Fills the frame, not the viewport: a 100vh sticky column would overhang it. */
+.workspace-frame .portal-sidebar {
+ height: 100%;
+ position: static;
+}
+
+@media (max-width: 48rem) {
+ .workspace-frame .portal-sidebar {
+ position: fixed;
+ height: auto;
+ }
}
diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx
index 685fa32722..2895ad4ec4 100644
--- a/frontend/editor/src/portal/components/Sidebar.tsx
+++ b/frontend/editor/src/portal/components/Sidebar.tsx
@@ -1,20 +1,16 @@
import { useMediaQuery } from "@mantine/hooks";
import { Tooltip } from "@mantine/core";
import { ActionIcon, NavItem, NavSurface } from "@app/ui";
-import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
+import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton";
+import { Logo } from "@app/ui/Logo";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary";
import { useOpenPlan } from "@portal/hooks/useOpenPlan";
-import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
import { useTranslation } from "react-i18next";
-import { useNavigate } from "react-router-dom";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import { useUI } from "@portal/contexts/UIContext";
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
-import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
-import { EDITOR_BASENAME } from "@app/routes/editorBasename";
-import { takeEditorReturnPath } from "@app/services/workbenchSession";
import { CloseIcon } from "@portal/components/icons";
import {
GROUP_PROCESSOR,
@@ -30,19 +26,17 @@ const NAV_SECTIONS: NavGroup[] = [
];
/** Must match the shell breakpoint in AppShell.css / Sidebar.css. */
-const MOBILE_QUERY = "(max-width: 48rem)";
+export const MOBILE_QUERY = "(max-width: 48rem)";
export function Sidebar() {
const { activeView, setActiveView } = useView();
const {
- openSettings,
mobileNavOpen,
closeMobileNav,
sidebarCollapsed,
toggleSidebarCollapsed,
} = useUI();
const { t } = useTranslation();
- const navigate = useNavigate();
const isMobile = useMediaQuery(MOBILE_QUERY, false, {
getInitialValueInEffect: false,
});
@@ -54,14 +48,6 @@ export function Sidebar() {
// off-canvas drawer, so the icon-rail state never applies there.
const collapsed = sidebarCollapsed && !isMobile;
- // Editor and portal are one SPA when the editor serves this origin's root, so
- // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup)
- // needs a full page load.
- const goToEditor = () => {
- if (EDITOR_IS_SAME_APP) navigate(takeEditorReturnPath() ?? EDITOR_BASENAME);
- else window.location.href = EDITOR_URL;
- };
-
// Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into
// a takeover modal (matching the marketing prototype).
@@ -107,25 +93,13 @@ export function Sidebar() {
// Off-canvas on mobile: remove from the tab order and accessibility tree.
inert={isMobile && !mobileNavOpen}
>
-
-
+
+ {!collapsed && }
-
-
-
+ }
collapsed={collapsed}
/>
diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx
index c64935bb16..ca040d4bbe 100644
--- a/frontend/editor/src/proprietary/App.tsx
+++ b/frontend/editor/src/proprietary/App.tsx
@@ -18,6 +18,8 @@ const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage"));
const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage"));
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions";
+import { AppFrame } from "@app/components/layout/AppFrame";
+import { NoAppChrome } from "@app/components/layout/NoAppChrome";
import { RootGate } from "@app/routes/RootGate";
// Import global styles
@@ -80,40 +82,52 @@ export default function App() {
}
/>
- {/* Admin-only route-set (the portal): its own top-level shell, mounted
- before the catch-all. Absent from core/desktop builds (empty stub). */}
- {getAdminRouteExtensions()}
+ {/* Both apps, under a shared frame so the rail renders once outside them. */}
+ }>
+ {/* The portal: its own shell, before the catch-all. An empty stub in core. */}
+ {getAdminRouteExtensions()}
- {/* All other routes need AppProviders for backend integration.
- RootGate makes "/" route by role BEFORE any of it mounts, so a user
- bound for the processor never boots the editor on the way. */}
-
-
-
-
- } />
- {/* Self-hosted has no signup - accounts are created by an
- admin. Old links land on login instead. */}
- }
- />
- } />
- } />
- } />
- {/* The editor and its tool routes - Landing handles auth logic */}
- } />
-
-
- {WATCHED_FOLDERS_ENABLED && }
-
-
-
- }
- />
+ {/* All other routes need AppProviders for backend integration. RootGate
+ routes "/" by role before any of it mounts. */}
+
+
+
+
+ {/* Not the app: no rail over any of these, ever. */}
+ }>
+ } />
+ {/* Self-hosted has no signup: old links land on login. */}
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+ {/* The editor and its tool routes - Landing handles auth logic */}
+ } />
+
+
+ {WATCHED_FOLDERS_ENABLED && }
+
+
+
+ }
+ />
+
);
diff --git a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx b/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx
deleted file mode 100644
index 9ba0b6438d..0000000000
--- a/frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Logo } from "@app/ui/Logo";
-import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
-import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
-import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
-
-/**
- * Sidebar brand header for builds that ship the processor. When this user can
- * open it, the Stirling logo doubles as the editor⇄processor switcher: the mark
- * morphs into a chevron and opens the switch menu (the same BrandSwitcher the
- * processor sidebar uses). Users without access get a plain logo.
- *
- * The access gate lives in {@link useOtherAppSwitch} so this header and the
- * sidebar footer's "Open PDF Processor" row are driven by one answer.
- */
-export function AppSwitcher({ collapsed }: AppSwitcherProps) {
- const otherApp = useOtherAppSwitch();
-
- if (!otherApp) {
- return (
-
- );
- }
-
- return (
-
- );
-}
diff --git a/frontend/editor/src/proprietary/data/processorEntitySearch.ts b/frontend/editor/src/proprietary/data/processorEntitySearch.ts
index 544babf59f..58b32b64d3 100644
--- a/frontend/editor/src/proprietary/data/processorEntitySearch.ts
+++ b/frontend/editor/src/proprietary/data/processorEntitySearch.ts
@@ -6,15 +6,10 @@ import type {
PortalEntityItems,
PortalEntityScopeId,
} from "@portal/search/entitySearch";
+import { HAS_PORTAL } from "@app/routes/hasPortal";
type EntitySearchModule = typeof import("@portal/search/entitySearch");
-// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
-// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
-// fetch or offer entities that have nowhere to open.
-const includePortal =
- import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
-
const NO_GROUPS: SuperSearchGroup[] = [];
const NO_SCOPES: readonly PortalEntityScopeId[] = [];
@@ -41,7 +36,8 @@ export function useProcessorEntityGroups(
): SuperSearchGroup[] {
const [mod, setMod] = useState(null);
const modRef = useRef(null);
- const active = enabled && includePortal;
+ // Without the portal these entities have nowhere to open, so don't fetch them.
+ const active = enabled && HAS_PORTAL;
const hasQuery = trimmed.length > 0;
useEffect(() => {
diff --git a/frontend/editor/src/proprietary/data/processorSearchIndex.ts b/frontend/editor/src/proprietary/data/processorSearchIndex.ts
index ae48893e25..8d99b2f77a 100644
--- a/frontend/editor/src/proprietary/data/processorSearchIndex.ts
+++ b/frontend/editor/src/proprietary/data/processorSearchIndex.ts
@@ -3,15 +3,10 @@ import { PORTAL_BASENAME } from "@app/routes/portalBasename";
// the lazy portal chunk into the main bundle the way @portal/* values would.
import { usersCapabilities } from "@app/portal/usersCapabilities";
import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
+import { HAS_PORTAL } from "@app/routes/hasPortal";
export type { ProcessorSearchEntry };
-// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
-// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
-// offer destinations that would 404 elsewhere.
-const includePortal =
- import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
-
/**
* The portal's in-app views. Deliberately a static mirror of the portal's nav
* (labels via the same portal.nav.* keys its sidebar uses) rather than an
@@ -91,7 +86,8 @@ const VIEWS: ProcessorSearchEntry[] = [
},
];
-export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal
+// Empty without the portal: these destinations would 404.
+export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = HAS_PORTAL
? VIEWS
: [];
diff --git a/frontend/editor/src/proprietary/routes/Landing.tsx b/frontend/editor/src/proprietary/routes/Landing.tsx
index 62c9083aad..7406078d30 100644
--- a/frontend/editor/src/proprietary/routes/Landing.tsx
+++ b/frontend/editor/src/proprietary/routes/Landing.tsx
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
import { useAppConfig } from "@app/contexts/AppConfigContext";
+import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext";
import HomePage from "@app/pages/HomePage";
import { useBackendProbe } from "@app/hooks/useBackendProbe";
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
@@ -27,6 +28,9 @@ export default function Landing() {
const loading = authLoading || configLoading || backendProbe.loading;
+ // The backend-down screen is not the app. Loading is: it resolves in a moment.
+ useSuppressQuickNavRail(!session && backendProbe.status !== "up");
+
// Debug: Track Landing component lifecycle
useEffect(() => {
const mountId = Math.random().toString(36).substring(7);
diff --git a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx
index 22ce46f03c..a422376086 100644
--- a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx
+++ b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx
@@ -2,11 +2,9 @@ import { lazy } from "react";
import type { ReactElement } from "react";
import { Route } from "react-router-dom";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
+import { HAS_PORTAL } from "@app/routes/hasPortal";
-const includePortal =
- import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
-
-const PortalApp = includePortal
+const PortalApp = HAS_PORTAL
? lazy(async () => {
const m = await import("@portal/PortalApp");
return { default: m.PortalApp };
@@ -16,7 +14,7 @@ const PortalApp = includePortal
/**
* Return leg of the account-link handshake, which Stirling redirects to with the admin's session in the URL fragment.
*/
-const ConnectCallback = includePortal
+const ConnectCallback = HAS_PORTAL
? lazy(async () => {
const m = await import("@portal/views/ConnectCallback");
return { default: m.default };
diff --git a/frontend/editor/src/proprietary/routes/hasPortal.ts b/frontend/editor/src/proprietary/routes/hasPortal.ts
new file mode 100644
index 0000000000..e3c54bc50e
--- /dev/null
+++ b/frontend/editor/src/proprietary/routes/hasPortal.ts
@@ -0,0 +1,3 @@
+/** Dev always ships it, so the switch is there to work on. */
+export const HAS_PORTAL =
+ import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx
index 2e091ebf05..07a563ea5f 100644
--- a/frontend/editor/src/saas/App.tsx
+++ b/frontend/editor/src/saas/App.tsx
@@ -19,6 +19,8 @@ import OAuthConsent from "@app/routes/OAuthConsent";
import ConnectApprove from "@app/routes/ConnectApprove";
import ShareLinkPage from "@app/routes/ShareLinkPage";
import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions";
+import { AppFrame } from "@app/components/layout/AppFrame";
+import { NoAppChrome } from "@app/components/layout/NoAppChrome";
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
import UsageLimitModalHost from "@app/components/UsageLimitModalHost";
@@ -96,46 +98,63 @@ export default function App() {
}
/>
- {/* Admin-only route-set (the portal): its own top-level shell, mounted
- before the catch-all. */}
- {getAdminRouteExtensions()}
+ {/* Both apps, under a shared frame so the rail renders once outside them. */}
+ }>
+ {/* The portal: its own top-level shell, before the catch-all. */}
+ {getAdminRouteExtensions()}
- {/* Everything else needs the auth/backend providers. RootGate makes "/"
- route by role BEFORE any of it mounts, so a user bound for the
- processor never boots the editor on the way. */}
-
-
-
-
-
-
- } />
- } />
- } />
- } />
- } />
- {/* Human half of the self-hosted account-link handshake. It
- lives on this origin because a customer hostname can
- never be in the provider's redirect allow-list. */}
- } />
- {/* Shared-file links. Team invites are NOT routed here: on
- SaaS they are accepted in-app via the Supabase team
- invitation banner, not the Spring password-based
- /invite/:token page used by the self-hosted build. */}
- } />
- } />
-
-
-
-
-
- }
- />
+ {/* Everything else needs the auth/backend providers. RootGate routes "/"
+ by role before any of it mounts. */}
+
+
+
+
+
+
+ {/* Not the app: no rail over any of these, ever. */}
+ }>
+ } />
+ } />
+ }
+ />
+ } />
+ }
+ />
+ {/* Human half of the self-hosted account-link handshake.
+ It lives on this origin because a customer hostname can
+ never be in the provider's redirect allow-list. Grouped
+ with the pages above: it is an approval step, not the
+ app. */}
+ } />
+ {/* Shared-file links. Team invites are NOT routed here:
+ on SaaS they are accepted in-app via the Supabase team
+ invitation banner, not the Spring password-based
+ /invite/:token page used by the self-hosted build. */}
+ }
+ />
+
+ } />
+
+
+
+
+
+ }
+ />
+
);
From a48356a2d22babeae7ad7c66673bee9a405fa083 Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:17:21 +0100
Subject: [PATCH 09/27] Deploy a dev SaaS server alongside the PR previews and
main demo (#7697)
---
.github/workflows/Saas-Dev-Deploy.yml | 246 ++++++++++++++++++++++++++
1 file changed, 246 insertions(+)
create mode 100644 .github/workflows/Saas-Dev-Deploy.yml
diff --git a/.github/workflows/Saas-Dev-Deploy.yml b/.github/workflows/Saas-Dev-Deploy.yml
new file mode 100644
index 0000000000..733b92bca2
--- /dev/null
+++ b/.github/workflows/Saas-Dev-Deploy.yml
@@ -0,0 +1,246 @@
+name: Auto SaaS Dev Deployment
+
+on:
+ push:
+ branches:
+ - saas-prod
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ FRONTEND_PORT: "901"
+ BACKEND_PORT: "902"
+ DEPLOY_DIR: /stirling/SAAS-DEV
+
+jobs:
+ deploy-saas-dev:
+ runs-on: ubuntu-latest
+ environment: saas-dev
+ concurrency:
+ group: saas-dev-deploy
+ cancel-in-progress: true
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Harden Runner
+ uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
+ with:
+ egress-policy: audit
+
+ - name: Check SaaS configuration
+ id: config
+ env:
+ PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
+ run: |
+ echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT"
+ echo "meter_endpoint=https://${PROJECT_REF}.supabase.co/functions/v1/meter-payg-units" >> "$GITHUB_OUTPUT"
+
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ github.token }}
+
+ - name: Convert repository owner to lowercase
+ id: repoowner
+ run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
+
+ - name: Get commit hash
+ id: commit-hash
+ run: echo "app_short=$(git rev-parse --short=8 HEAD)" >> $GITHUB_OUTPUT
+
+ - name: Build and push backend image
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
+ with:
+ context: .
+ file: ./docker/backend/Dockerfile
+ push: true
+ cache-from: type=gha,scope=stirling-saas-backend
+ cache-to: type=gha,mode=max,scope=stirling-saas-backend
+ tags: |
+ ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-${{ steps.commit-hash.outputs.app_short }}
+ ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-backend-latest
+ build-args: |
+ VERSION_TAG=v2-alpha
+ STIRLING_FLAVOR=saas
+ platforms: linux/amd64
+
+ - name: Build and push frontend image
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
+ with:
+ context: .
+ file: ./docker/frontend/Dockerfile
+ push: true
+ cache-from: type=gha,scope=stirling-saas-frontend
+ cache-to: type=gha,mode=max,scope=stirling-saas-frontend
+ tags: |
+ ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-${{ steps.commit-hash.outputs.app_short }}
+ ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-frontend-latest
+ build-args: |
+ VERSION_TAG=v2-alpha
+ STIRLING_FLAVOR=saas
+ VITE_BUILD_MODE=development
+ VITE_SUPABASE_URL=${{ steps.config.outputs.supabase_url }}
+ VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }}
+ platforms: linux/amd64
+
+ - name: Build and push AI engine image
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
+ with:
+ context: .
+ file: ./engine/Dockerfile
+ push: true
+ cache-from: type=gha,scope=stirling-saas-engine
+ cache-to: type=gha,mode=max,scope=stirling-saas-engine
+ tags: |
+ ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-${{ steps.commit-hash.outputs.app_short }}
+ ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:saas-engine-latest
+ platforms: linux/amd64
+
+ - name: Set up SSH
+ env:
+ SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
+ run: |
+ mkdir -p ~/.ssh/
+ echo "$SSH_KEY" > ../private.key
+ sudo chmod 600 ../private.key
+
+ - name: Deploy to VPS
+ env:
+ IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
+ IMAGE_TAG: ${{ steps.commit-hash.outputs.app_short }}
+ GHCR_USER: ${{ github.actor }}
+ GHCR_TOKEN: ${{ github.token }}
+ VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
+ VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
+ SAAS_DB_URL: ${{ secrets.SAAS_DB_URL }}
+ SAAS_DB_USERNAME: ${{ secrets.SAAS_DB_USERNAME || 'postgres' }}
+ SAAS_DB_PASSWORD: ${{ secrets.SAAS_DB_PASSWORD }}
+ SAAS_DB_PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }}
+ SUPABASE_EDGE_FUNCTION_SECRET: ${{ secrets.SUPABASE_EDGE_FUNCTION_SECRET }}
+ PAYG_METER_ENDPOINT: ${{ steps.config.outputs.meter_endpoint }}
+ STIRLING_KEYGEN_ENABLED: ${{ secrets.KEYGEN_ACCOUNT_ID != '' && secrets.KEYGEN_API_TOKEN != '' && secrets.KEYGEN_POLICY_ID != '' }}
+ KEYGEN_ACCOUNT_ID: ${{ secrets.KEYGEN_ACCOUNT_ID }}
+ KEYGEN_API_TOKEN: ${{ secrets.KEYGEN_API_TOKEN }}
+ KEYGEN_POLICY_ID: ${{ secrets.KEYGEN_POLICY_ID }}
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }}
+ run: |
+ set -euo pipefail
+
+ BASE_URL="http://${VPS_HOST}:${FRONTEND_PORT}"
+
+ yaml() {
+ printf "'%s'" "$(printf '%s' "$1" | sed -e "s/'/''/g" -e 's/\$/$$/g')"
+ }
+
+ ENGINE_SECRET="$(openssl rand -hex 32)"
+ AI_BACKEND_VARS="
+ SYSTEM_AIENGINE_ENABLED: \"true\"
+ SYSTEM_AIENGINE_URL: \"http://saas-engine:5001\"
+ APP_AI_SERVICEBASEURL: \"http://saas-engine:5001\"
+ STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")"
+ AI_SERVICE="
+
+ saas-engine:
+ container_name: stirling-saas-dev-engine
+ image: ${IMAGE_BASE}:saas-engine-${IMAGE_TAG}
+ environment:
+ ANTHROPIC_API_KEY: $(yaml "$ANTHROPIC_API_KEY")
+ VOYAGE_API_KEY: $(yaml "$VOYAGE_API_KEY")
+ STIRLING_ENGINE_SHARED_SECRET: $(yaml "$ENGINE_SECRET")
+ restart: on-failure:5"
+
+ cat > docker-compose.yml << EOF
+ version: '3.3'
+ services:
+ saas-backend:
+ container_name: stirling-saas-dev-backend
+ image: ${IMAGE_BASE}:saas-backend-${IMAGE_TAG}
+ ports:
+ - "${BACKEND_PORT}:8080"
+ volumes:
+ - ${DEPLOY_DIR}/config:/configs:rw
+ - ${DEPLOY_DIR}/logs:/logs:rw
+ - ${DEPLOY_DIR}/storage:/storage:rw
+ environment:
+ SPRING_PROFILES_ACTIVE: "saas"
+ DISABLE_ADDITIONAL_FEATURES: "false"
+ SAAS_DB_URL: $(yaml "$SAAS_DB_URL")
+ SAAS_DB_USERNAME: $(yaml "$SAAS_DB_USERNAME")
+ SAAS_DB_PASSWORD: $(yaml "$SAAS_DB_PASSWORD")
+ SAAS_DB_PROJECT_REF: $(yaml "$SAAS_DB_PROJECT_REF")
+ SUPABASE_EDGE_FUNCTION_SECRET: $(yaml "$SUPABASE_EDGE_FUNCTION_SECRET")
+ PAYG_METER_ENDPOINT: $(yaml "$PAYG_METER_ENDPOINT")
+ STIRLING_KEYGEN_ENABLED: $(yaml "$STIRLING_KEYGEN_ENABLED")
+ KEYGEN_ACCOUNT_ID: $(yaml "$KEYGEN_ACCOUNT_ID")
+ KEYGEN_API_TOKEN: $(yaml "$KEYGEN_API_TOKEN")
+ KEYGEN_POLICY_ID: $(yaml "$KEYGEN_POLICY_ID")
+ SYSTEM_DEFAULTLOCALE: en-US
+ SYSTEM_MAXFILESIZE: "100"
+ METRICS_ENABLED: "true"
+ SYSTEM_GOOGLEVISIBILITY: "false"
+ SWAGGER_SERVER_URL: "${BASE_URL}"
+ baseUrl: "${BASE_URL}"${AI_BACKEND_VARS}
+ restart: on-failure:5
+
+ saas-frontend:
+ container_name: stirling-saas-dev-frontend
+ image: ${IMAGE_BASE}:saas-frontend-${IMAGE_TAG}
+ ports:
+ - "${FRONTEND_PORT}:80"
+ environment:
+ VITE_API_BASE_URL: "http://saas-backend:8080"
+ depends_on:
+ - saas-backend
+ restart: on-failure:5${AI_SERVICE}
+ EOF
+
+ SSH_OPTS=(-i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
+
+ scp "${SSH_OPTS[@]}" docker-compose.yml "${VPS_USERNAME}@${VPS_HOST}:/tmp/saas-dev-docker-compose.yml"
+
+ ssh "${SSH_OPTS[@]}" -T "${VPS_USERNAME}@${VPS_HOST}" << ENDSSH
+ set -e
+ mkdir -p ${DEPLOY_DIR}/{config,logs,storage}
+ mv /tmp/saas-dev-docker-compose.yml ${DEPLOY_DIR}/docker-compose.yml
+ chmod 600 ${DEPLOY_DIR}/docker-compose.yml
+ cd ${DEPLOY_DIR}
+ printf '%s' "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin
+ docker-compose down --remove-orphans 2>/dev/null || true
+ docker-compose pull
+ docker-compose up -d
+ docker logout ghcr.io >/dev/null 2>&1 || true
+ docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
+ ENDSSH
+
+ - name: Wait for the backend to answer
+ env:
+ VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
+ run: |
+ URL="http://${VPS_HOST}:${BACKEND_PORT}/api/v1/info/status"
+ for i in $(seq 1 60); do
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL" || true)
+ if [ "$code" = "200" ]; then echo "Healthy after $((i * 10))s"; exit 0; fi
+ sleep 10
+ done
+ echo "::error::SaaS dev backend did not become healthy within 10 minutes"
+ exit 1
+
+ - name: Cleanup temporary files
+ if: always()
+ run: rm -f ../private.key docker-compose.yml
+ continue-on-error: true
From 849d616451feb79b706ad6f9a9250924327b0020 Mon Sep 17 00:00:00 2001
From: James Brunton
Date: Thu, 27 Aug 2026 23:45:10 +0100
Subject: [PATCH 10/27] Fix refreshing causing you to go to the Processor
(#7694)
---
.../common/util/RequestUriUtilsTest.java | 10 ++++++++
.../configuration/SecurityConfiguration.java | 6 ++---
.../src/proprietary/routes/Login.test.tsx | 23 ++++++++++++++++++-
.../editor/src/proprietary/routes/Login.tsx | 8 +++++--
.../proprietary/services/apiClientSetup.ts | 4 ++++
5 files changed, 45 insertions(+), 6 deletions(-)
diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java
index 72e5eae9a2..c144f9726e 100644
--- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java
+++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java
@@ -113,6 +113,16 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf"));
}
+ @Test
+ void testIsFrontendRoute_editorRouteOwnedByFrontend() {
+ // /editor (and its tool routes) is an SPA route: a direct-nav/refresh must
+ // serve index.html, not the auth filter's 302-to-/login. Regression test for
+ // the editor moving from / to /editor, whose refresh bounced processor users
+ // to the processor because the redirect dropped the return path.
+ assertTrue(RequestUriUtils.isFrontendRoute("", "/editor"));
+ assertTrue(RequestUriUtils.isFrontendRoute("/app", "/app/editor"));
+ }
+
@Test
void testIsFrontendRoute_filesRouteOwnedByFrontend() {
// /files and /files/ are FileManagerView routes - they
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java
index 9fc4428f73..0f0c7315d9 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java
@@ -357,12 +357,12 @@ public class SecurityConfiguration {
req -> {
String uri = req.getRequestURI();
String contextPath = req.getContextPath();
- // Check if it's a public auth endpoint or static
- // resource
return RequestUriUtils.isStaticResource(
contextPath, uri)
|| RequestUriUtils.isPublicAuthEndpoint(
- uri, contextPath);
+ uri, contextPath)
+ || RequestUriUtils.isFrontendRoute(
+ contextPath, uri);
})
.permitAll()
.anyRequest()
diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx
index 29ae9d9d79..5553f202fb 100644
--- a/frontend/editor/src/proprietary/routes/Login.test.tsx
+++ b/frontend/editor/src/proprietary/routes/Login.test.tsx
@@ -234,7 +234,10 @@ describe("Login", () => {
);
};
- afterEach(() => window.history.replaceState({}, "", "/"));
+ afterEach(() => {
+ window.history.replaceState({}, "", "/");
+ sessionStorage.clear();
+ });
it("returns to where the user came from", async () => {
signedIn();
@@ -247,6 +250,24 @@ describe("Login", () => {
});
});
+ // A full-page 401 redirect drops router state and Spring can strip ?from=,
+ // leaving the return path only in the sessionStorage stash. Without reading
+ // it here a processor user refreshing /editor falls through to the role
+ // router and lands on the processor.
+ it("returns to the stashed path when there is no ?from=", async () => {
+ signedIn();
+ sessionStorage.setItem("stirling_post_login_path", "/compress");
+ renderAtLogin("");
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith("/compress", {
+ replace: true,
+ });
+ });
+ // Consumed, so a later sign-in can't reuse a stale path.
+ expect(sessionStorage.getItem("stirling_post_login_path")).toBeNull();
+ });
+
// Delegated to the shared isSafePostLoginRedirect, so the backslash form
// (browsers normalise "\" to "/") and auth routes are covered too.
it.each([
diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx
index b134d9ee89..7fd3e86670 100644
--- a/frontend/editor/src/proprietary/routes/Login.tsx
+++ b/frontend/editor/src/proprietary/routes/Login.tsx
@@ -7,7 +7,10 @@ import {
} from "react-router-dom";
import { Button } from "@app/ui/Button";
import { isSafePostLoginRedirect } from "@app/auth";
-import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient";
+import {
+ setPostLoginRedirectPath,
+ consumePostLoginRedirectPath,
+} from "@app/auth/spring/springAuthClient";
import { useAuth } from "@app/auth/UseSession";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useTranslation } from "react-i18next";
@@ -207,7 +210,8 @@ export default function Login() {
useEffect(() => {
if (loading) return;
if (!session) return;
- const returnPath = resolveReturnPath();
+ const stashed = consumePostLoginRedirectPath();
+ const returnPath = resolveReturnPath() ?? stashed;
if (returnPath) {
navigate(returnPath, { replace: true });
return;
diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts
index 8615156e9a..3f50e1bb4c 100644
--- a/frontend/editor/src/proprietary/services/apiClientSetup.ts
+++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts
@@ -1,6 +1,7 @@
import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios";
import { withBasePath } from "@app/constants/app";
import { getBrowserId } from "@app/utils/browserIdentifier";
+import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient";
let isRefreshing = false;
let failedQueue: Array<{
@@ -93,6 +94,9 @@ async function refreshAuthToken(client: AxiosInstance): Promise {
// Redirect to login
const loginPath = withBasePath("/login");
if (window.location.pathname !== loginPath) {
+ setPostLoginRedirectPath(
+ window.location.pathname + window.location.search,
+ );
console.log("[API Client] Redirecting to login page...");
window.location.href = loginPath;
}
From 0a3f0c181474e1b0ebd03744330481aee6f495be Mon Sep 17 00:00:00 2001
From: James Brunton
Date: Fri, 28 Aug 2026 10:03:52 +0000
Subject: [PATCH 11/27] Fix redirect bugs in SaaS (#7721)
# Description of Changes
Fixes various bugs that affected SaaS (and some self-hosted):
- Refreshing on Editor caused the user to be redirected to Processor
- User was unable to access Processor in SaaS
- Deep link hijacking fixes
- Fix double prefix `/app/app` issue
- Fix going from tool -> editor -> processor -> editor putting you back
into tool
---------
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
---
.../shared/quickNav/QuickNavRailHost.tsx | 4 +-
.../src/core/contexts/ToolWorkflowContext.tsx | 16 +++
.../editor/src/core/hooks/useUrlSync.test.tsx | 80 +++++++++++
frontend/editor/src/core/hooks/useUrlSync.ts | 30 +++-
.../httpErrorHandler.basePath.test.ts | 76 +++++++++++
.../src/core/services/httpErrorHandler.ts | 28 ++--
.../core/services/postLoginRedirect.test.ts | 39 ++++++
.../src/core/services/postLoginRedirect.ts | 15 ++
.../core/services/workbenchSession.test.ts | 8 +-
.../src/core/services/workbenchSession.ts | 5 +-
.../auth/spring/springAuthClient.ts | 22 +--
.../proprietary/auth/supabase/UseSession.tsx | 14 +-
.../auth/supabase/portalAccessFetch.test.tsx | 128 ++++++++++++++++++
.../hooks/useOtherAppSwitch.test.tsx | 5 +-
.../proprietary/hooks/useOtherAppSwitch.ts | 5 +-
.../proprietary/services/apiClientSetup.ts | 6 +-
.../services/postLoginRedirect.test.ts | 25 ++++
.../proprietary/services/postLoginRedirect.ts | 7 +
.../src/saas/hooks/useOtherAppSwitch.ts | 5 +-
.../editor/src/saas/routes/AuthCallback.tsx | 21 ++-
frontend/editor/src/saas/routes/Login.tsx | 13 +-
.../src/saas/services/apiClient.test.ts | 51 ++++++-
.../editor/src/saas/services/apiClient.ts | 25 +++-
23 files changed, 545 insertions(+), 83 deletions(-)
create mode 100644 frontend/editor/src/core/hooks/useUrlSync.test.tsx
create mode 100644 frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts
create mode 100644 frontend/editor/src/core/services/postLoginRedirect.test.ts
create mode 100644 frontend/editor/src/core/services/postLoginRedirect.ts
create mode 100644 frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx
create mode 100644 frontend/editor/src/proprietary/services/postLoginRedirect.test.ts
create mode 100644 frontend/editor/src/proprietary/services/postLoginRedirect.ts
diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx
index 5c748b36cc..3fba4d7e1f 100644
--- a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx
+++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx
@@ -20,7 +20,7 @@ const SIZE = "1.125rem";
export function QuickNavRailHost() {
const { t } = useTranslation();
const navigate = useNavigate();
- const { pathname, search } = useLocation();
+ const { pathname } = useLocation();
const host = useQuickNavHost();
const appMounted = Boolean(host?.appMounted);
@@ -74,7 +74,7 @@ export function QuickNavRailHost() {
returnHome();
return;
}
- saveEditorReturnPath(pathname + search);
+ saveEditorReturnPath();
go(PORTAL_BASENAME);
},
},
diff --git a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx
index 070442a966..b4ef874184 100644
--- a/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx
+++ b/frontend/editor/src/core/contexts/ToolWorkflowContext.tsx
@@ -29,6 +29,8 @@ import {
isBaseWorkbench,
} from "@app/types/workbench";
import { useNavigationUrlSync } from "@app/hooks/useUrlSync";
+import { stripBasePath } from "@app/constants/app";
+import { EDITOR_BASENAME } from "@app/routes/editorBasename";
import { filterToolRegistryByQuery } from "@app/utils/toolSearch";
import { useToolHistory } from "@app/hooks/tools/useUserToolActivity";
import {
@@ -373,15 +375,28 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// This runs once to navigate to the user's preferred tab (read/automate)
// instead of always starting on the tools tab.
const hasAppliedStartupView = React.useRef(false);
+ // Set when the startup view picks the tool, so the URL sync knows this
+ // selection came from a preference and must not be written to the address.
+ const startupSelectedToolRef = React.useRef(null);
useEffect(() => {
if (hasAppliedStartupView.current) return;
+ // The URL wins: the startup view decides what you see when you arrive at the
+ // editor's home, never what a deep link to a tool shows. Without this, a
+ // "Reader" preference rewrote every / link to /read.
+ const path = stripBasePath(window.location.pathname);
+ if (path !== "/" && path !== EDITOR_BASENAME) {
+ hasAppliedStartupView.current = true;
+ return;
+ }
const startupView = preferences.defaultStartupView;
if (startupView === "read") {
hasAppliedStartupView.current = true;
+ startupSelectedToolRef.current = "read";
setReaderMode(true);
actions.setSelectedTool("read");
} else if (startupView === "automate") {
hasAppliedStartupView.current = true;
+ startupSelectedToolRef.current = "automate";
actions.setSelectedTool("automate");
setLeftPanelView("toolContent");
}
@@ -573,6 +588,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
handleBackToTools,
allTools,
true,
+ startupSelectedToolRef,
);
// Ref-backed wrappers so callback identities stay stable across renders.
diff --git a/frontend/editor/src/core/hooks/useUrlSync.test.tsx b/frontend/editor/src/core/hooks/useUrlSync.test.tsx
new file mode 100644
index 0000000000..e35c7dbd44
--- /dev/null
+++ b/frontend/editor/src/core/hooks/useUrlSync.test.tsx
@@ -0,0 +1,80 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { useRef } from "react";
+import type { ToolId } from "@app/types/toolId";
+
+const h = vi.hoisted(() => ({
+ updateToolRoute: vi.fn(),
+ clearToolRoute: vi.fn(),
+}));
+
+vi.mock("@app/utils/urlRouting", () => ({
+ parseToolRoute: () => ({ workbench: "fileEditor", toolId: null }),
+ updateToolRoute: h.updateToolRoute,
+ clearToolRoute: h.clearToolRoute,
+}));
+vi.mock("@app/utils/scarfTracking", () => ({ firePixel: vi.fn() }));
+vi.mock("@app/contexts/AppConfigContext", () => ({
+ useAppConfig: () => ({ config: { premiumEnabled: true } }),
+}));
+
+import { useNavigationUrlSync } from "@app/hooks/useUrlSync";
+
+const registry = {
+ read: { name: "Read", workbench: "viewer" },
+ compress: { name: "Compress", workbench: "fileEditor" },
+} as never;
+
+/** Drives the hook the way ToolWorkflowContext does, with a startup marker. */
+function useHarness(selectedTool: ToolId | null, startupTool: ToolId | null) {
+ const ref = useRef(startupTool);
+ useNavigationUrlSync(selectedTool, vi.fn(), vi.fn(), registry, true, ref);
+ return ref;
+}
+
+describe("useNavigationUrlSync — startup-view selections", () => {
+ beforeEach(() => h.updateToolRoute.mockClear());
+
+ // The default-startup-view preference selects a tool to change the *view*.
+ // Writing it to the address turned every visit to /editor into /read.
+ it("never writes the URL for the startup-applied tool", () => {
+ const { rerender } = renderHook(
+ ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId),
+ { initialProps: { tool: null as ToolId | null } },
+ );
+ rerender({ tool: "read" as ToolId });
+ expect(h.updateToolRoute).not.toHaveBeenCalled();
+ });
+
+ // The effect re-runs whenever the registry identity changes, so a marker that
+ // was consumed on first sight let the second run write /read anyway.
+ it("survives a re-run for the same tool", () => {
+ const { rerender } = renderHook(
+ ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId),
+ { initialProps: { tool: null as ToolId | null } },
+ );
+ rerender({ tool: "read" as ToolId });
+ rerender({ tool: "read" as ToolId });
+ rerender({ tool: "read" as ToolId });
+ expect(h.updateToolRoute).not.toHaveBeenCalled();
+ });
+
+ it("still writes the URL when the user picks a different tool", () => {
+ const { rerender } = renderHook(
+ ({ tool }: { tool: ToolId | null }) => useHarness(tool, "read" as ToolId),
+ { initialProps: { tool: null as ToolId | null } },
+ );
+ rerender({ tool: "read" as ToolId });
+ rerender({ tool: "compress" as ToolId });
+ expect(h.updateToolRoute).toHaveBeenCalledWith("compress", registry, false);
+ });
+
+ it("writes the URL for a tool chosen without a startup marker", () => {
+ const { rerender } = renderHook(
+ ({ tool }: { tool: ToolId | null }) => useHarness(tool, null),
+ { initialProps: { tool: null as ToolId | null } },
+ );
+ rerender({ tool: "read" as ToolId });
+ expect(h.updateToolRoute).toHaveBeenCalledWith("read", registry, false);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/useUrlSync.ts b/frontend/editor/src/core/hooks/useUrlSync.ts
index 5fad71ba29..76fd67178a 100644
--- a/frontend/editor/src/core/hooks/useUrlSync.ts
+++ b/frontend/editor/src/core/hooks/useUrlSync.ts
@@ -2,7 +2,7 @@
* URL synchronization hooks for tool routing with registry support
*/
-import { useEffect, useCallback, useRef } from "react";
+import { useEffect, useCallback, useRef, type MutableRefObject } from "react";
import { ToolId } from "@app/types/toolId";
import {
parseToolRoute,
@@ -24,6 +24,11 @@ export function useNavigationUrlSync(
clearToolSelection: () => void,
registry: ToolRegistry,
enableSync: boolean = true,
+ /**
+ * Tool the default-startup-view preference selected, if any. That selection
+ * sets the view, not the address, so it must not be written to the URL.
+ */
+ startupSelectedToolRef?: MutableRefObject,
) {
const { config } = useAppConfig();
const premiumEnabled = config?.premiumEnabled;
@@ -77,8 +82,16 @@ export function useNavigationUrlSync(
useEffect(() => {
if (!enableSync) return;
+ const startupTool = startupSelectedToolRef?.current ?? null;
+
if (selectedTool) {
- updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation
+ // A startup-view selection is a view preference, not a navigation: writing
+ // it here rewrote /editor to /read on every load. The effect re-runs
+ // whenever the registry identity changes, so the marker has to survive
+ // until the selection actually moves off it (cleared below).
+ if (startupTool !== selectedTool) {
+ updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation
+ }
} else if (prevSelectedTool.current !== null) {
// Only clear URL if we had a tool before (user navigated away)
// Don't clear on initial load when both current and previous are null
@@ -88,8 +101,19 @@ export function useNavigationUrlSync(
}
}
+ // Spent once the user leaves the startup-applied tool, so re-picking it
+ // later is a real navigation and does update the URL.
+ if (
+ startupSelectedToolRef &&
+ startupTool !== null &&
+ prevSelectedTool.current === startupTool &&
+ selectedTool !== startupTool
+ ) {
+ startupSelectedToolRef.current = null;
+ }
+
prevSelectedTool.current = selectedTool;
- }, [selectedTool, registry, enableSync]);
+ }, [selectedTool, registry, enableSync, startupSelectedToolRef]);
// Handle browser back/forward navigation
useEffect(() => {
diff --git a/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts b/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts
new file mode 100644
index 0000000000..27a4231a2c
--- /dev/null
+++ b/frontend/editor/src/core/services/httpErrorHandler.basePath.test.ts
@@ -0,0 +1,76 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
+vi.mock("@app/services/specialErrorToasts", () => ({
+ showSpecialErrorToast: vi.fn(() => false),
+}));
+vi.mock("@app/services/saasErrorInterceptor", () => ({
+ handleSaaSError: vi.fn(() => false),
+}));
+vi.mock("@app/services/errorUtils", () => ({
+ broadcastErroredFiles: vi.fn(),
+ extractErrorFileIds: vi.fn(() => []),
+ normalizeAxiosErrorData: vi.fn(async (d: unknown) => d),
+}));
+
+const hrefs: string[] = [];
+
+/** Serve the app from `base`, sitting on `pathname`, then load the handler fresh. */
+async function loadAt(base: string, pathname: string) {
+ document.head.innerHTML = ``;
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: {
+ pathname,
+ search: "",
+ origin: "http://localhost:3000",
+ get href() {
+ // Absolute: jsdom resolves against this.
+ return "http://localhost:3000" + pathname;
+ },
+ set href(v: string) {
+ hrefs.push(v);
+ },
+ },
+ });
+ vi.resetModules();
+ return (await import("@app/services/httpErrorHandler")).handleHttpError;
+}
+
+const unauthorized = {
+ isAxiosError: true,
+ message: "unauthorized",
+ config: {},
+ response: { status: 401, data: {} },
+};
+
+beforeEach(() => {
+ hrefs.length = 0;
+ sessionStorage.clear();
+ localStorage.clear();
+});
+afterEach(() => vi.resetModules());
+
+describe("401 return path is router-relative", () => {
+ // Login replays this through navigate(), which re-applies the router
+ // basename. Carrying /app here produced /app/app/compress.
+ it("strips the base path on a subpath deploy", async () => {
+ const handle = await loadAt("/app/", "/app/compress");
+ await handle(unauthorized);
+
+ expect(sessionStorage.getItem("stirling_post_login_path")).toBe(
+ "/compress",
+ );
+ expect(hrefs[0]).toBe("/app/login?from=%2Fcompress");
+ });
+
+ it("is unchanged at the origin root", async () => {
+ const handle = await loadAt("/", "/compress");
+ await handle(unauthorized);
+
+ expect(sessionStorage.getItem("stirling_post_login_path")).toBe(
+ "/compress",
+ );
+ expect(hrefs[0]).toBe("/login?from=%2Fcompress");
+ });
+});
diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts
index dd0c692d97..30272c381e 100644
--- a/frontend/editor/src/core/services/httpErrorHandler.ts
+++ b/frontend/editor/src/core/services/httpErrorHandler.ts
@@ -12,7 +12,8 @@ import {
clampText,
extractAxiosErrorMessage,
} from "@app/services/httpErrorUtils";
-import { withBasePath } from "@app/constants/app";
+import { stripBasePath, withBasePath } from "@app/constants/app";
+import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
// Module-scoped state to reduce global variable usage
const recentSpecialByEndpoint: Record = {};
@@ -21,26 +22,9 @@ const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate
// Mirrors the key in proprietary/auth/springAuthClient.ts; AuthCallback consumes it.
const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path";
-function isSafePostLoginPath(path: string): boolean {
- if (
- !path.startsWith("/") ||
- path.startsWith("//") ||
- path.startsWith("/\\")
- ) {
- return false;
- }
- const lowered = path.toLowerCase();
- return (
- !lowered.startsWith("/login") &&
- !lowered.startsWith("/auth/") &&
- !lowered.startsWith("/oauth2") &&
- !lowered.startsWith("/saml2")
- );
-}
-
function stashPostLoginRedirect(path: string): void {
try {
- if (typeof window === "undefined" || !isSafePostLoginPath(path)) return;
+ if (typeof window === "undefined" || !isSafePostLoginRedirect(path)) return;
window.sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, path);
} catch {
// sessionStorage unavailable (private mode) — fail open
@@ -128,7 +112,11 @@ export async function handleHttpError(error: unknown): Promise {
console.debug("[httpErrorHandler] 401 detected, redirecting to login");
// Spring 302-strips the ?from= query from /login, so stash the return
// path in sessionStorage (AuthCallback reads it after SSO round-trip).
- const currentLocation = window.location.pathname + window.location.search;
+ // Router-relative, not browser-relative: every consumer replays this
+ // through navigate(), which re-applies the basename. Keeping the base
+ // path here yields /app/app/ on a subpath deploy.
+ const currentLocation =
+ stripBasePath(window.location.pathname) + window.location.search;
stashPostLoginRedirect(currentLocation);
let hadStoredJwt = false;
try {
diff --git a/frontend/editor/src/core/services/postLoginRedirect.test.ts b/frontend/editor/src/core/services/postLoginRedirect.test.ts
new file mode 100644
index 0000000000..9f1687f392
--- /dev/null
+++ b/frontend/editor/src/core/services/postLoginRedirect.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
+
+// Core default. Rejects off-origin forms and the auth routes every build has
+// (/login, /auth/…); Spring SSO routes are the proprietary override's concern.
+describe("isSafePostLoginRedirect (core base)", () => {
+ it("accepts same-origin router paths", () => {
+ expect(isSafePostLoginRedirect("/editor")).toBe(true);
+ expect(isSafePostLoginRedirect("/compress")).toBe(true);
+ expect(isSafePostLoginRedirect("/editor?foo=bar")).toBe(true);
+ expect(isSafePostLoginRedirect("/oauth/consent?x=1")).toBe(true);
+ expect(isSafePostLoginRedirect("/")).toBe(true);
+ });
+
+ it("rejects empty and non-string values", () => {
+ expect(isSafePostLoginRedirect(null)).toBe(false);
+ expect(isSafePostLoginRedirect(undefined)).toBe(false);
+ expect(isSafePostLoginRedirect("")).toBe(false);
+ expect(isSafePostLoginRedirect(42 as unknown)).toBe(false);
+ });
+
+ it("rejects off-origin and protocol-relative forms", () => {
+ expect(isSafePostLoginRedirect("//evil.example.com")).toBe(false);
+ expect(isSafePostLoginRedirect("/\\evil.example.com")).toBe(false);
+ expect(isSafePostLoginRedirect("https://evil.example.com")).toBe(false);
+ expect(isSafePostLoginRedirect("editor")).toBe(false);
+ });
+
+ it("rejects the universal auth routes so returning back can never loop", () => {
+ expect(isSafePostLoginRedirect("/login")).toBe(false);
+ expect(isSafePostLoginRedirect("/login?next=%2Feditor")).toBe(false);
+ expect(isSafePostLoginRedirect("/auth/callback")).toBe(false);
+ });
+
+ it("leaves the Spring SSO routes to the proprietary override", () => {
+ expect(isSafePostLoginRedirect("/oauth2/authorize")).toBe(true);
+ expect(isSafePostLoginRedirect("/saml2/login")).toBe(true);
+ });
+});
diff --git a/frontend/editor/src/core/services/postLoginRedirect.ts b/frontend/editor/src/core/services/postLoginRedirect.ts
new file mode 100644
index 0000000000..d9ffe82940
--- /dev/null
+++ b/frontend/editor/src/core/services/postLoginRedirect.ts
@@ -0,0 +1,15 @@
+/**
+ * Is `path` safe to send a user back to after they log in?
+ */
+export function isSafePostLoginRedirect(path: unknown): path is string {
+ if (typeof path !== "string" || path.length === 0) return false;
+ if (
+ !path.startsWith("/") ||
+ path.startsWith("//") ||
+ path.startsWith("/\\")
+ ) {
+ return false;
+ }
+ const lowered = path.toLowerCase();
+ return !lowered.startsWith("/login") && !lowered.startsWith("/auth/");
+}
diff --git a/frontend/editor/src/core/services/workbenchSession.test.ts b/frontend/editor/src/core/services/workbenchSession.test.ts
index d9feec8d1c..0258b83623 100644
--- a/frontend/editor/src/core/services/workbenchSession.test.ts
+++ b/frontend/editor/src/core/services/workbenchSession.test.ts
@@ -55,10 +55,14 @@ describe("workbench session record", () => {
});
describe("editor return path", () => {
- it("is consumed by the first take", () => {
- saveEditorReturnPath("/compress?x=1");
+ it("captures the live address bar and is consumed by the first take", () => {
+ // The editor writes its tool route via raw history.pushState, so the save
+ // must read window.location, not a lagging router location.
+ window.history.pushState({}, "", "/compress?x=1");
+ saveEditorReturnPath();
expect(takeEditorReturnPath()).toBe("/compress?x=1");
expect(takeEditorReturnPath()).toBeNull();
+ window.history.pushState({}, "", "/");
});
});
diff --git a/frontend/editor/src/core/services/workbenchSession.ts b/frontend/editor/src/core/services/workbenchSession.ts
index 67f6d1abfc..2f23db8655 100644
--- a/frontend/editor/src/core/services/workbenchSession.ts
+++ b/frontend/editor/src/core/services/workbenchSession.ts
@@ -2,6 +2,7 @@
// does not cost the user their workbench. sessionStorage on purpose: per-tab, tabs never clobber.
import type { StirlingFileStub } from "@app/types/fileContext";
+import { stripBasePath } from "@app/constants/app";
const SESSION_KEY = "stirling.workbench.session";
/** Bumped when the record's shape or meaning changes, so an old one is discarded rather than
@@ -153,8 +154,10 @@ export function isSeedableView(
return view !== undefined && SEEDABLE_VIEWS.includes(view);
}
-export function saveEditorReturnPath(path: string): void {
+export function saveEditorReturnPath(): void {
try {
+ const path =
+ stripBasePath(window.location.pathname) + window.location.search;
sessionStorage.setItem(RETURN_PATH_KEY, path);
} catch {
// Best-effort: the switch back just lands on the editor root.
diff --git a/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts b/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts
index b9583e3dee..6d887ce8a9 100644
--- a/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts
+++ b/frontend/editor/src/proprietary/auth/spring/springAuthClient.ts
@@ -16,6 +16,7 @@ import { AxiosError, type AxiosRequestConfig } from "axios";
import { getSpringAuthConfig } from "@app/auth/config";
import { type OAuthProvider } from "@app/auth/spring/oauthTypes";
import { resetOAuthState } from "@app/auth/spring/oauthStorage";
+import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
import type {
AuthUser as User,
AuthSession as Session,
@@ -100,23 +101,10 @@ function persistRedirectPath(path: string): void {
}
}
-// Same-origin relative path, not pointing at auth plumbing. Rejects protocol-relative
-// URLs to guard against open-redirect abuse if the stored value is tampered with.
-export function isSafePostLoginRedirect(path: unknown): path is string {
- if (typeof path !== "string" || path.length === 0) return false;
- if (!path.startsWith("/") || path.startsWith("//")) return false;
- if (path.startsWith("/\\")) return false;
- const lowered = path.toLowerCase();
- if (
- lowered.startsWith("/login") ||
- lowered.startsWith("/auth/") ||
- lowered.startsWith("/oauth2") ||
- lowered.startsWith("/saml2")
- ) {
- return false;
- }
- return true;
-}
+// The safe-return-path rule lives in the shared @app/services/postLoginRedirect
+// extension point (proprietary override adds the Spring SSO routes). Re-exported
+// here so existing importers via @app/auth keep resolving it.
+export { isSafePostLoginRedirect };
export function setPostLoginRedirectPath(
path: string | null | undefined,
diff --git a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx
index 385f3f4e35..07670bfff5 100644
--- a/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx
+++ b/frontend/editor/src/proprietary/auth/supabase/UseSession.tsx
@@ -16,6 +16,7 @@ import type {
import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
import { AuthContext } from "@app/auth/context";
import { isAdminRole } from "@app/auth/roles";
+import { getApiBaseUrl } from "@app/services/apiClientConfig";
import {
defaultTranslate,
type AuthContextValue,
@@ -154,14 +155,23 @@ export function SupabaseAuthProvider({
return;
}
let cancelled = false;
+ // Same API base the rest of the app uses: SaaS serves the frontend and the
+ // API from different hosts, so a root-relative path never reaches /me.
+ const apiBase = (getApiBaseUrl() || "").replace(/\/+$/, "");
+ const meUrl = `${apiBase}/api/v1/auth/me`;
const loadAccess = () => {
- void fetch("/api/v1/auth/me", {
+ void fetch(meUrl, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
})
- .then((res) => (res.ok ? res.json() : null))
+ .then((res) => {
+ // Must throw, not resolve null: swallowing a non-ok leaves
+ // portalAccess undefined and hangs the portal gate on a spinner.
+ if (!res.ok) throw new Error(`auth/me responded ${res.status}`);
+ return res.json();
+ })
.then(
(
data: {
diff --git a/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx b/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx
new file mode 100644
index 0000000000..3b3d9b37c8
--- /dev/null
+++ b/frontend/editor/src/proprietary/auth/supabase/portalAccessFetch.test.tsx
@@ -0,0 +1,128 @@
+import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
+import { render, waitFor } from "@testing-library/react";
+import { useContext } from "react";
+
+const h = vi.hoisted(() => ({ apiBase: "/" }));
+
+vi.mock("@app/services/apiClientConfig", () => ({
+ getApiBaseUrl: () => h.apiBase,
+}));
+
+const sbSession = {
+ access_token: "supabase-token",
+ user: {
+ id: "u1",
+ email: "user@example.com",
+ is_anonymous: false,
+ app_metadata: {},
+ user_metadata: {},
+ },
+};
+
+vi.mock("@app/auth/supabase/supabaseClient", () => ({
+ getSupabaseClient: () => ({
+ auth: {
+ getSession: () => Promise.resolve({ data: { session: sbSession } }),
+ onAuthStateChange: () => ({
+ data: { subscription: { unsubscribe: () => {} } },
+ }),
+ refreshSession: () => Promise.resolve({ data: {}, error: null }),
+ signOut: () => Promise.resolve({ error: null }),
+ },
+ }),
+}));
+
+import { SupabaseAuthProvider } from "@app/auth/supabase/UseSession";
+import { AuthContext } from "@app/auth/context";
+
+function Probe() {
+ const v = useContext(AuthContext);
+ return (
+ <>
+ {String(v?.portalAccess)}
+ {/* Raw, un-defaulted value: this is what SaasPortalGate reads to decide
+ "access not known yet" vs "denied". undefined = spinner forever. */}
+ {String(v?.user?.portalAccess)}
+ >
+ );
+}
+
+const mount = () =>
+ render(
+
+
+ ,
+ );
+
+let fetchMock: ReturnType;
+
+beforeEach(() => {
+ fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+});
+afterEach(() => vi.unstubAllGlobals());
+
+describe("supabase provider portalAccess lookup", () => {
+ // SaaS serves the frontend and the API from different hosts; a root-relative
+ // path silently missed /me, so a granted non-admin was denied the Processor.
+ it("calls /me on the configured API base, not the page origin", async () => {
+ h.apiBase = "https://api.example.com";
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ user: { portalAccess: true } }),
+ });
+ mount();
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://api.example.com/api/v1/auth/me",
+ expect.anything(),
+ ),
+ );
+ });
+
+ it("keeps a same-origin base as a single leading slash", async () => {
+ h.apiBase = "/";
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ user: { portalAccess: true } }),
+ });
+ mount();
+ await waitFor(() =>
+ expect(fetchMock).toHaveBeenCalledWith(
+ "/api/v1/auth/me",
+ expect.anything(),
+ ),
+ );
+ });
+
+ it("grants access when /me says so", async () => {
+ h.apiBase = "https://api.example.com";
+ fetchMock.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ user: { portalAccess: true } }),
+ });
+ const { getByTestId } = mount();
+ await waitFor(() => expect(getByTestId("access").textContent).toBe("true"));
+ });
+
+ // A non-ok used to resolve to null and return early, leaving the raw
+ // portalAccess undefined forever - SaasPortalGate reads that as "still
+ // loading" and hangs on a spinner instead of falling back.
+ it("resolves the raw portalAccess when /me returns non-ok", async () => {
+ h.apiBase = "https://api.example.com";
+ fetchMock.mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: () => Promise.resolve({}),
+ });
+ const { getByTestId } = mount();
+ await waitFor(() => expect(getByTestId("raw").textContent).toBe("false"));
+ });
+
+ it("leaves the raw portalAccess defined when the request rejects", async () => {
+ h.apiBase = "https://api.example.com";
+ fetchMock.mockRejectedValue(new Error("network down"));
+ const { getByTestId } = mount();
+ await waitFor(() => expect(getByTestId("raw").textContent).toBe("false"));
+ });
+});
diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx
index c2a81c53d9..4acdc7a17a 100644
--- a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx
+++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.test.tsx
@@ -7,9 +7,10 @@ const mocks = vi.hoisted(() => ({
portalAccess: true,
}));
+// The hook reads window.location for the return path (not useLocation), because
+// the editor's raw history.pushState leaves react-router's location stale.
vi.mock("react-router-dom", () => ({
useNavigate: () => mocks.navigate,
- useLocation: () => ({ pathname: "/compress", search: "?mode=fast" }),
}));
vi.mock("@app/auth/context", () => ({
useAuth: () => ({ portalAccess: mocks.portalAccess }),
@@ -27,6 +28,7 @@ beforeEach(() => {
sessionStorage.clear();
vi.clearAllMocks();
mocks.portalAccess = true;
+ window.history.pushState({}, "", "/");
});
describe("useOtherAppSwitch", () => {
@@ -45,6 +47,7 @@ describe("useOtherAppSwitch", () => {
});
it("records where to return to, then navigates to the processor", () => {
+ window.history.pushState({}, "", "/compress?mode=fast");
const { result } = renderHook(() => useOtherAppSwitch());
result.current?.onOpen();
mocks.requestNavigation.mock.calls[0][0]();
diff --git a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts
index dff36a06f4..69b55aff35 100644
--- a/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts
+++ b/frontend/editor/src/proprietary/hooks/useOtherAppSwitch.ts
@@ -1,4 +1,4 @@
-import { useLocation, useNavigate } from "react-router-dom";
+import { useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/context";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
@@ -12,7 +12,6 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote
export function useOtherAppSwitch(): NavFooterAppLink | null {
const { portalAccess } = useAuth();
const navigate = useNavigate();
- const location = useLocation();
const { actions } = useNavigationActions();
if (!portalAccess) return null;
return {
@@ -20,7 +19,7 @@ export function useOtherAppSwitch(): NavFooterAppLink | null {
onOpen: () =>
// Through the guard, so unsaved edits get the same warning as any other navigation.
actions.requestNavigation(() => {
- saveEditorReturnPath(location.pathname + location.search);
+ saveEditorReturnPath();
navigate(PORTAL_BASENAME);
}),
};
diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts
index 3f50e1bb4c..459f527080 100644
--- a/frontend/editor/src/proprietary/services/apiClientSetup.ts
+++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts
@@ -1,5 +1,5 @@
import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios";
-import { withBasePath } from "@app/constants/app";
+import { stripBasePath, withBasePath } from "@app/constants/app";
import { getBrowserId } from "@app/utils/browserIdentifier";
import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient";
@@ -94,8 +94,10 @@ async function refreshAuthToken(client: AxiosInstance): Promise {
// Redirect to login
const loginPath = withBasePath("/login");
if (window.location.pathname !== loginPath) {
+ // Router-relative: Login replays this through navigate(), which applies
+ // the basename itself. See the same note in httpErrorHandler.
setPostLoginRedirectPath(
- window.location.pathname + window.location.search,
+ stripBasePath(window.location.pathname) + window.location.search,
);
console.log("[API Client] Redirecting to login page...");
window.location.href = loginPath;
diff --git a/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts b/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts
new file mode 100644
index 0000000000..6f2451f336
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/postLoginRedirect.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
+
+// Proprietary override: the core base plus the Spring SSO routes (/oauth2, /saml2).
+describe("isSafePostLoginRedirect (proprietary override)", () => {
+ it("still accepts ordinary router paths", () => {
+ expect(isSafePostLoginRedirect("/editor")).toBe(true);
+ expect(isSafePostLoginRedirect("/share/abc123?x=1")).toBe(true);
+ expect(isSafePostLoginRedirect("/")).toBe(true);
+ });
+
+ it("inherits the base rejections", () => {
+ expect(isSafePostLoginRedirect("")).toBe(false);
+ expect(isSafePostLoginRedirect(null)).toBe(false);
+ expect(isSafePostLoginRedirect("//evil.example")).toBe(false);
+ expect(isSafePostLoginRedirect("/\\evil")).toBe(false);
+ expect(isSafePostLoginRedirect("/login")).toBe(false);
+ expect(isSafePostLoginRedirect("/auth/callback")).toBe(false);
+ });
+
+ it("also rejects the Spring SSO routes", () => {
+ expect(isSafePostLoginRedirect("/oauth2/authorization/google")).toBe(false);
+ expect(isSafePostLoginRedirect("/saml2/authenticate/x")).toBe(false);
+ });
+});
diff --git a/frontend/editor/src/proprietary/services/postLoginRedirect.ts b/frontend/editor/src/proprietary/services/postLoginRedirect.ts
new file mode 100644
index 0000000000..5cd6546fa7
--- /dev/null
+++ b/frontend/editor/src/proprietary/services/postLoginRedirect.ts
@@ -0,0 +1,7 @@
+import { isSafePostLoginRedirect as isSafeBaseRedirect } from "@core/services/postLoginRedirect";
+
+export function isSafePostLoginRedirect(path: unknown): path is string {
+ if (!isSafeBaseRedirect(path)) return false;
+ const lowered = path.toLowerCase();
+ return !lowered.startsWith("/oauth2") && !lowered.startsWith("/saml2");
+}
diff --git a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts
index 67d0aa7d49..e97d4d0ed3 100644
--- a/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts
+++ b/frontend/editor/src/saas/hooks/useOtherAppSwitch.ts
@@ -1,4 +1,4 @@
-import { useLocation, useNavigate } from "react-router-dom";
+import { useNavigate } from "react-router-dom";
import { usePortalAccess } from "@app/hooks/usePortalAccess";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
@@ -13,7 +13,6 @@ import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFoote
export function useOtherAppSwitch(): NavFooterAppLink | null {
const portalAccess = usePortalAccess();
const navigate = useNavigate();
- const location = useLocation();
const { actions } = useNavigationActions();
if (!portalAccess) return null;
return {
@@ -21,7 +20,7 @@ export function useOtherAppSwitch(): NavFooterAppLink | null {
onOpen: () =>
// Through the guard, so unsaved edits get the same warning as any other navigation.
actions.requestNavigation(() => {
- saveEditorReturnPath(location.pathname + location.search);
+ saveEditorReturnPath();
navigate(PORTAL_BASENAME);
}),
};
diff --git a/frontend/editor/src/saas/routes/AuthCallback.tsx b/frontend/editor/src/saas/routes/AuthCallback.tsx
index b69619b85d..3fe081ed82 100644
--- a/frontend/editor/src/saas/routes/AuthCallback.tsx
+++ b/frontend/editor/src/saas/routes/AuthCallback.tsx
@@ -5,6 +5,7 @@ import { supabase } from "@app/auth/supabase";
import { Button } from "@app/ui/Button";
import { withBasePath } from "@app/constants/app";
import { readPendingConnect } from "@app/routes/pendingConnect";
+import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
import { AuthShell } from "@app/auth/ui/AuthShell";
import ErrorMessage from "@app/auth/ui/ErrorMessage";
import { Spinner } from "@app/ui/Spinner";
@@ -136,18 +137,16 @@ export default function AuthCallback() {
// else on the editor.
// Explicit `next` first, so a sign-in started for another reason is not
// hijacked by a remembered connect request.
- const explicitNext = url.searchParams.get("next");
+ const explicitNext =
+ url.searchParams.get("next") ?? url.searchParams.get("from");
const pendingConnect = readPendingConnect();
- const destination =
- explicitNext &&
- explicitNext.startsWith("/") &&
- !explicitNext.startsWith("//")
- ? explicitNext
- : pendingConnect
- ? `/link?request=${encodeURIComponent(pendingConnect)}`
- : next.startsWith("/") && !next.startsWith("//")
- ? next
- : await resolveLandingPath();
+ const destination = isSafePostLoginRedirect(explicitNext)
+ ? explicitNext
+ : pendingConnect
+ ? `/link?request=${encodeURIComponent(pendingConnect)}`
+ : isSafePostLoginRedirect(next)
+ ? next
+ : await resolveLandingPath();
console.log("[Auth Callback Debug] Redirecting to:", destination);
setTimeout(() => navigate(destination, { replace: true }), 1500);
diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx
index 418616104e..ba251561fd 100644
--- a/frontend/editor/src/saas/routes/Login.tsx
+++ b/frontend/editor/src/saas/routes/Login.tsx
@@ -14,6 +14,7 @@ import {
getBaseUrl,
withBasePath,
} from "@app/constants/app";
+import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
import LinkRoundedIcon from "@mui/icons-material/LinkRounded";
// Import login components
@@ -48,14 +49,14 @@ export default function Login() {
}
}, []);
- // Same-origin relative path to return to after login (e.g. the OAuth
- // consent page). Same sanitization rules as AuthCallback's `next`.
+ // Same-origin router path to return to after login (e.g. the OAuth consent
+ // page, or the editor a 401 bounced the user off). `?next=` is what this app
+ // writes; `?from=` is what the shared core 401 handler writes.
const nextPath = useMemo(() => {
try {
- const next = new URL(window.location.href).searchParams.get("next");
- return next && next.startsWith("/") && !next.startsWith("//")
- ? next
- : null;
+ const params = new URL(window.location.href).searchParams;
+ const candidate = params.get("next") ?? params.get("from");
+ return isSafePostLoginRedirect(candidate) ? candidate : null;
} catch (_) {
return null;
}
diff --git a/frontend/editor/src/saas/services/apiClient.test.ts b/frontend/editor/src/saas/services/apiClient.test.ts
index 45646bf8d8..af4a1d90f4 100644
--- a/frontend/editor/src/saas/services/apiClient.test.ts
+++ b/frontend/editor/src/saas/services/apiClient.test.ts
@@ -219,10 +219,11 @@ describe("apiClient", () => {
// Import apiClient after mocking
const { default: apiClient } = await import("@app/services/apiClient");
- // Mock window.location for redirect test
+ // On /editor when the session dies: the return path must ride along so the
+ // login screen sends the user back here, not to the role-based landing.
Object.defineProperty(window, "location", {
writable: true,
- value: { href: "" },
+ value: { href: "", pathname: "/editor", search: "" },
});
const mockAdapter = vi.fn((config) => {
@@ -245,8 +246,50 @@ describe("apiClient", () => {
} catch (_) {
// Verify refresh was attempted
expect(supabase.auth.refreshSession).toHaveBeenCalled();
- // Verify redirect to login
- expect(window.location.href).toBe("/login");
+ // Verify redirect to login carries the return path
+ expect(window.location.href).toBe("/login?next=%2Feditor");
+ }
+ });
+
+ it("does not redirect (or loop) when already on the login page", async () => {
+ expectConsole.error(/\[API Client\] Token refresh failed/);
+ const oldSession = { access_token: "old", user: { id: "user-123" } };
+ vi.mocked(supabase.auth.getSession).mockResolvedValue({
+ data: { session: oldSession as unknown as Session },
+ error: null,
+ });
+ vi.mocked(supabase.auth.refreshSession).mockResolvedValue({
+ data: { user: null, session: null },
+ error: {
+ name: "AuthError",
+ message: "Refresh failed",
+ status: 400,
+ code: "auth_error",
+ } as unknown as AuthError,
+ });
+
+ const { default: apiClient } = await import("@app/services/apiClient");
+
+ Object.defineProperty(window, "location", {
+ writable: true,
+ value: { href: "", pathname: "/login", search: "?next=%2Feditor" },
+ });
+
+ apiClient.defaults.adapter = vi.fn((config) =>
+ Promise.reject(
+ Object.assign(new Error("Unauthorized"), {
+ response: { status: 401, data: { error: "Unauthorized" } },
+ config,
+ }),
+ ),
+ );
+
+ try {
+ await apiClient.get("/api/v1/test");
+ expect(true).toBe(false);
+ } catch (_) {
+ // Left untouched: no second redirect off the login page.
+ expect(window.location.href).toBe("");
}
});
});
diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts
index 64cd05fd01..73ebe97e47 100644
--- a/frontend/editor/src/saas/services/apiClient.ts
+++ b/frontend/editor/src/saas/services/apiClient.ts
@@ -5,8 +5,9 @@ import {
classifyPaygError,
handlePaygError,
} from "@app/services/paygErrorInterceptor";
-import { withBasePath } from "@app/constants/app";
+import { stripBasePath, withBasePath } from "@app/constants/app";
import { getBrowserId } from "@app/utils/browserIdentifier";
+import { isSafePostLoginRedirect } from "@app/services/postLoginRedirect";
// Helper: decode base64url JWT payload safely
function decodeJwtPayload(token: string): Record | null {
@@ -113,6 +114,21 @@ function refreshSessionOnce(): ReturnType {
return inFlightRefresh;
}
+// Hard-redirect to /login, carrying where the user was so the login screen can
+// return them there instead of falling through to the role-based landing (which
+// sends processor users to the processor - the "refresh /editor bounces me to
+// the processor" bug). Router-relative, matching what Login reads via `?next=`.
+function redirectToLogin(): void {
+ const loginPath = withBasePath("/login");
+ // Already on the login page: another redirect would just loop.
+ if (window.location.pathname === loginPath) return;
+ const returnPath =
+ stripBasePath(window.location.pathname) + window.location.search;
+ window.location.href = isSafePostLoginRedirect(returnPath)
+ ? `${loginPath}?next=${encodeURIComponent(returnPath)}`
+ : loginPath;
+}
+
// Response interceptor for handling token refresh
apiClient.interceptors.response.use(
(response) => response,
@@ -173,7 +189,7 @@ apiClient.interceptors.response.use(
// The session genuinely can't be recovered. Send protected requests
// to login; public ones just fail quietly (no redirect).
if (!isPublicEndpoint) {
- window.location.href = withBasePath("/login");
+ redirectToLogin();
}
return Promise.reject(error);
@@ -194,10 +210,7 @@ apiClient.interceptors.response.use(
console.debug(
"[API Client] No session to refresh, 401 on protected endpoint",
);
- const loginPath = withBasePath("/login");
- if (window.location.pathname !== loginPath) {
- window.location.href = loginPath;
- }
+ redirectToLogin();
return Promise.reject(error);
}
} catch (refreshError) {
From 658aa54c20446f9f1188f59cf35fb82fa86bc81e Mon Sep 17 00:00:00 2001
From: James Brunton
Date: Fri, 28 Aug 2026 10:35:41 +0000
Subject: [PATCH 12/27] Update tool models to fix main (#7725)
# Description of Changes
When #6697 merged, the CI didn't run for some reason so it was never
caught that the tool models were out of date. This PR updates them to
the correct state.
---
engine/src/stirling/models/tool_models.py | 3 +++
frontend/editor/src/core/types/toolApiTypes.ts | 4 ++++
2 files changed, 7 insertions(+)
diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py
index 6650942b6f..dff977a7f7 100644
--- a/engine/src/stirling/models/tool_models.py
+++ b/engine/src/stirling/models/tool_models.py
@@ -725,6 +725,9 @@ class OcrPdfParams(ApiModel):
)
ocr_type: OcrType = Field(..., description="Specify the OCR type, e.g., 'skip-text', 'force-ocr', or 'Normal'")
remove_images_after: bool | None = Field(None, description="Remove images from the output PDF if set to true")
+ rotate_pages: bool | None = Field(
+ None, description="Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true"
+ )
sidecar: bool | None = Field(None, description="Include OCR text in a sidecar text file if set to true")
diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts
index 55938d231e..d980029624 100644
--- a/frontend/editor/src/core/types/toolApiTypes.ts
+++ b/frontend/editor/src/core/types/toolApiTypes.ts
@@ -1022,6 +1022,10 @@ export interface ProcessPdfWithOcrRequest {
* Remove images from the output PDF if set to true
*/
removeImagesAfter?: boolean;
+ /**
+ * Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true
+ */
+ rotatePages?: boolean;
/**
* Include OCR text in a sidecar text file if set to true
*/
From 4ab2505a6c8fe2ed469576b6d4e93d7565b2567a Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Fri, 28 Aug 2026 10:56:50 +0000
Subject: [PATCH 13/27] Comment-quality standard, and the gate that enforces it
(#7663)
## The problem
AI PRs write comments that restate the line below them, mark sections
with box drawing, and narrate the diff. Nothing in the repo said not to,
and nothing checked. `AGENTS.md` had one line about comments and it was
buried in the Python section.
Banners and `Step N:` narration have zero occurrences in the 15 months
before Aug 2025, so this is new.
## The fix
A written standard, plus a linter that enforces the mechanical part of
it on added lines only.
-
[devGuide/CODE_COMMENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/devGuide/CODE_COMMENTS.md)
holds the reasoning and worked examples; a section in
[AGENTS.md](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/AGENTS.md)
holds the operative rules, kept short so they stay in an agent's
context. The two are split by kind rather than duplicated, because the
same prose in two places drifts.
- Rules in
[comment-rules.mjs](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs),
shared by both engines.
- Two engines. `.ts` / `.tsx` / `.mjs` go to an [oxlint JS
plugin](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-oxlint-plugin.mjs)
so comments come from the parser rather than a line scan; `.java` /
`.py` go to a [line
scanner](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint.mjs).
Neither reads the other's files, so they cannot disagree about one file.
- Between them they read every comment form the repo writes: `//` and
`/* */`, Javadoc and JSDoc, JSX comments, `#`, and Python docstrings.
- Runs in `task pre-commit`, so the git hook and the `pre_commit.yml` CI
job both get it, and as a Claude Code [`Stop`
hook](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-lint-hook.mjs)
so an agent fixes the comment inside the turn that wrote it.
## The rules
The part worth arguing about. **Every rule blocks.** A rule that only
warns is a rule nobody acts on, so a finding you believe is wrong is a
bug in the rule: narrow it, or mark the line and say why.
| | Fires on |
| --- | --- |
|
[CMT001](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L71)
| Every word in the comment already appears in the code below it. Max 6
words, skipped for prose punctuation and for a bare Arrange/Act/Assert
marker |
|
[CMT002](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L92)
| 4+ rule or box-drawing characters, or a bare section label from [a
fixed
list](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L84)
(`Types`, `Helpers`, `State`, `Handlers`, ...) |
|
[CMT003](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L110)
| `Step N:` with a separator, or `Then,` / `Next,` / `Finally,`.
Suppressed in test files |
|
[CMT004](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L129)
| A comment about the code's own past: `this used to`, `renamed from`,
`was previously called`. Suppressed in test files |
|
[CMT005](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L154)
| 3+ consecutive comment lines where 2/3 [parse as
code](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L143)
|
|
[CMT006](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L31)
| A run of implementation comment over 12 lines, outside the first 5
lines of a file. Doc blocks are exempt, because the standard asks for
thorough contracts |
|
[CMT007](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L180)
| A parameter or return description that adds no word its name lacks.
Reads Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google `name:
description` |
|
[CMT008](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L239)
| An allow directive naming a rule that does not exist, or one that
silenced nothing |
|
[CMT009](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L219)
| A `TODO` / `FIXME` / `HACK` naming no issue or link. An owner is not
accepted: a username goes stale, an issue outlives it |
Each rule carries the readings it deliberately excludes, next to the
rule. Those exclusions came from running the rules over this repo, not
from taste: `CMT004` does not match a bare "no longer needed" because
that is as often about runtime lifecycle as about history, and `CMT003`
needs a separator after the number so a wrapped line beginning "step 2
unmounts + remounts the panel" reads as the prose it is.
A comment sharing a line with code is judged by the rules that do not
depend on the code below it, so a trailing `// TODO fix this` or `/*
this used to run before the flush */` still reports, while `50L * 1024 *
1024 // 50 MB` does not. `CMT001` would have been wrong about six in
seven trailing comments here, so it stays out of them.
If a finding is wrong, `// comment-lint-allow: CMT002` on the line
above. Rule-specific, [no blanket
disable](https://github.com/Stirling-Tools/Stirling-PDF/blob/claude/ai-pr-comment-quality-dd970e/scripts/lint/comment-rules.mjs#L229).
A directive naming a rule that does not exist, or silencing nothing, is
itself a `CMT008` failure, so a typo cannot quietly disable a rule and a
stale one gets deleted rather than accumulating.
No native linter covers `CMT007`. `eslint-plugin-jsdoc`'s
`require-param-description`, Checkstyle's `NonEmptyAtclauseDescription`
and ruff's D-rules all check that a description exists, not whether it
says anything.
## Scoping
Added comment **text** only, not lines git calls new. Reindenting a file
or moving a block makes git mark untouched comments as added; findings
are matched against the comment text at the base, so only genuinely new
content reports.
The whole file is read and every comment in it evaluated. Only the
*reporting* is filtered, so a rule still sees the code a comment
introduces, the full run it belongs to, and the base version of the
file.
Existing tree is untouched. `task pre-commit:comment-lint:all` reports
it and always exits 0:
| | java | ts/js | py |
| --- | --- | --- | --- |
| findings | 1,218 | 741 | 204 |
2,163 across 542 files, mostly `CMT002` banners (1,482) and `CMT001`
restatements (456). Clearing it is separate work, by directory.
Not in this PR: an advisory LLM review layer for the things no pattern
can judge.
## Verification
Run against
[#7494](https://github.com/Stirling-Tools/Stirling-PDF/pull/7494) as CI
would, in a throwaway worktree: **two findings on a 78 file, +4,512 line
change, both genuine banners, in 952ms**. A whole-file scan of those
same files gives 11; the other 9 were withheld because that PR's author
did not write them, and they are the `@param teamId the team ID` shape
this standard exists to stop.
Both scanners blank string and character literals before looking for
comment markers, because a partial lex desynchronises everything after
it: one apostrophe in a Java comment, or one Python template whose
closing quotes start a line, is enough to read dozens of lines of code
as a single comment. Two fixtures carry canaries that stop being
reported if either engine ever desynchronises again.
The [fixture
corpus](https://github.com/Stirling-Tools/Stirling-PDF/tree/claude/ai-pr-comment-quality-dd970e/scripts/lint/fixtures)
pins all 9 rules against both engines, and `--selftest` fails if the two
disagree about the same file.
## Two things reviewers should know
**The oxlint JS plugin API is alpha.** oxlint itself is stable and
already this repo's frontend linter; the plugin API is the new
dependency. Its documented failure mode
([oxc#25203](https://github.com/oxc-project/oxc/issues/25203)) is being
skipped silently while oxlint still reports success. That affects the
standalone release binary rather than the npm package this invokes, but
the class of failure reads exactly like clean code, so the run asserts
`number_of_rules >= 1` from oxlint's own report and a broken engine
exits 2 rather than passing. If the API ever breaks, the fallback is
folding these rules into the line scanner, which already implements all
nine for Java and Python.
**`.claude/settings.json` is now committed**, carrying the hook and
nothing else: 19 lines, no `permissions`, nothing machine-specific. That
partly reverts `c35546a212` ("Ignore claude dir"), which existed because
this file had twice been committed by accident with a personal
`permissions` allowlist, once with absolute machine paths. Personal
config still belongs in `.claude/settings.local.json`, which the new
pattern keeps ignored, and hook entries merge across the two so nobody's
own hooks are lost.
If you already hand-wrote a `.claude/settings.json`, copy it somewhere
first: that path used to be git-ignored, and git overwrites an ignored
file without warning when a commit starts tracking it. Across 19 local
checkouts here, 13 have `settings.local.json` and none has a
hand-written `settings.json`.
To turn the hook off, `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in local
settings. Claude Code can only disable all hooks at once, hence the
switch. The commit-time gate still applies.
## How to test
```bash
task pre-commit:comment-lint:ci
```
The fixture corpus, then the diff. The corpus checks the rules
themselves rather than the code under review, so it runs on CI and
before a rule change, not on every local commit.
```bash
task comment-lint:branch
```
`clean (34 files in scope)`. `task comment-lint` is the same thing
scoped to uncommitted work, which is what the git hook and CI run.
To watch it bite, add `// Is banner` above `export function isBanner` in
`scripts/lint/comment-rules.mjs` and run `task comment-lint`: one
`CMT001`, exit 1. The gate covers its own source, which is why these
scripts have no section dividers.
```bash
task pre-commit:comment-lint:all
```
The standing backlog, report-only.
Verified on the pinned oxlint 1.77.0, not only the 1.79 the plugin was
prototyped against.
---
.claude/settings.json | 19 +
.github/pull_request_template.md | 1 +
.github/workflows/pre_commit.yml | 5 +
.gitignore | 9 +-
.taskfiles/pre-commit.yml | 82 +++
AGENTS.md | 39 +-
CONTRIBUTING.md | 1 +
Taskfile.yml | 14 +
devGuide/CODE_COMMENTS.md | 232 ++++++
devGuide/README.md | 1 +
frontend/oxlint.comments.config.ts | 31 +
scripts/lint/comment-lint-hook.mjs | 119 ++++
scripts/lint/comment-lint-oxlint-plugin.mjs | 126 ++++
scripts/lint/comment-lint.mjs | 743 ++++++++++++++++++++
scripts/lint/comment-rules.mjs | 500 +++++++++++++
scripts/lint/fixtures/AaaTest.java | 19 +
scripts/lint/fixtures/README.md | 17 +
scripts/lint/fixtures/allow.java | 32 +
scripts/lint/fixtures/apostrophes.java | 23 +
scripts/lint/fixtures/clean.java | 26 +
scripts/lint/fixtures/clean.ts | 17 +
scripts/lint/fixtures/deadcode.java | 14 +
scripts/lint/fixtures/docs.java | 12 +
scripts/lint/fixtures/docstrings.py | 36 +
scripts/lint/fixtures/expected.json | 63 ++
scripts/lint/fixtures/narration.java | 22 +
scripts/lint/fixtures/narration.tsx | 17 +
scripts/lint/fixtures/restates.java | 24 +
scripts/lint/fixtures/restates.py | 9 +
scripts/lint/fixtures/restates.ts | 20 +
scripts/lint/fixtures/strings.java | 11 +
scripts/lint/fixtures/templates.py | 24 +
scripts/lint/fixtures/todos.java | 25 +
scripts/lint/fixtures/trailing.java | 22 +
34 files changed, 2352 insertions(+), 3 deletions(-)
create mode 100644 .claude/settings.json
create mode 100644 devGuide/CODE_COMMENTS.md
create mode 100644 frontend/oxlint.comments.config.ts
create mode 100644 scripts/lint/comment-lint-hook.mjs
create mode 100644 scripts/lint/comment-lint-oxlint-plugin.mjs
create mode 100644 scripts/lint/comment-lint.mjs
create mode 100644 scripts/lint/comment-rules.mjs
create mode 100644 scripts/lint/fixtures/AaaTest.java
create mode 100644 scripts/lint/fixtures/README.md
create mode 100644 scripts/lint/fixtures/allow.java
create mode 100644 scripts/lint/fixtures/apostrophes.java
create mode 100644 scripts/lint/fixtures/clean.java
create mode 100644 scripts/lint/fixtures/clean.ts
create mode 100644 scripts/lint/fixtures/deadcode.java
create mode 100644 scripts/lint/fixtures/docs.java
create mode 100644 scripts/lint/fixtures/docstrings.py
create mode 100644 scripts/lint/fixtures/expected.json
create mode 100644 scripts/lint/fixtures/narration.java
create mode 100644 scripts/lint/fixtures/narration.tsx
create mode 100644 scripts/lint/fixtures/restates.java
create mode 100644 scripts/lint/fixtures/restates.py
create mode 100644 scripts/lint/fixtures/restates.ts
create mode 100644 scripts/lint/fixtures/strings.java
create mode 100644 scripts/lint/fixtures/templates.py
create mode 100644 scripts/lint/fixtures/todos.java
create mode 100644 scripts/lint/fixtures/trailing.java
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000000..40d0b73379
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json.schemastore.org/claude-code-settings.json",
+ "hooks": {
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node",
+ "args": [
+ "${CLAUDE_PROJECT_DIR}/scripts/lint/comment-lint-hook.mjs"
+ ],
+ "timeout": 60
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index d9eb6dbe10..41666ff608 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -20,6 +20,7 @@ Closes #(issue_number)
- [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable)
- [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable)
- [ ] I have performed a self-review of my own code
+- [ ] Every comment I added says something the code does not ([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md))
- [ ] My changes generate no new warnings
### Documentation
diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml
index 674822363b..3566f5864f 100644
--- a/.github/workflows/pre_commit.yml
+++ b/.github/workflows/pre_commit.yml
@@ -38,3 +38,8 @@ jobs:
- name: Run pre-commit checks
run: task pre-commit
+
+ # The fixture corpus checks the comment rules themselves, so it runs here
+ # rather than on every local commit.
+ - name: Check the comment-lint fixture corpus
+ run: task pre-commit:comment-lint:selftest
diff --git a/.gitignore b/.gitignore
index 1290056e05..ca683dc5d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -298,8 +298,13 @@ docs/type3/signatures/
**/application-dev-local.properties
-# Claude
-.claude/
+# Claude. Contents are ignored so personal config stays local, with the two
+# shared pieces re-included: settings.json (the comment-lint hook) and skills/.
+# The directory itself cannot be ignored or git will not look inside it.
+.claude/*
+!.claude/settings.json
+!.claude/skills/
+.claude/settings.local.json
# Playwright MCP screenshots / traces
.playwright-mcp/
diff --git a/.taskfiles/pre-commit.yml b/.taskfiles/pre-commit.yml
index 444115010f..f65a296160 100644
--- a/.taskfiles/pre-commit.yml
+++ b/.taskfiles/pre-commit.yml
@@ -11,6 +11,7 @@ vars:
'.github/scripts/*.py'
'app/core/src/main/resources/static/python/*.py'
':(exclude)*split_photos.py'
+ ':(exclude)scripts/lint/fixtures/*'
SPELL_FILES: >-
'*.html'
'*.css'
@@ -59,6 +60,7 @@ tasks:
- task: gitleaks
- task: whitespace
- task: toml-sort
+ - task: comment-lint
fix:
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
@@ -75,6 +77,7 @@ tasks:
vars: { FIX: '1' }
- task: codespell
- task: gitleaks
+ - task: comment-lint
install:
desc: "Install the pinned pre-commit Python tools"
@@ -130,6 +133,85 @@ tasks:
cmds:
- "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose"
+ comment-lint:
+ desc: "Check comment quality on the lines this branch adds"
+ summary: |
+ Blocks a comment that restates the code below it, a section banner, or a
+ block of commented-out code. Everything else it reports is advisory.
+
+ Scoped to added lines, so touching an old file never surfaces the standing
+ backlog. The standard is devGuide/CODE_COMMENTS.md.
+
+ With no arguments it diffs the working tree against HEAD, which is what a
+ pre-commit run wants: the lines you are about to commit. On a CI pull request
+ it diffs against the target branch instead, via GITHUB_BASE_REF.
+
+ To ask what a whole branch adds instead, use the branch variant, which
+ needs no argument passing:
+ task comment-lint:branch
+
+ Full tree (report only): task pre-commit:comment-lint:all
+ Fixture corpus: task pre-commit:comment-lint:selftest
+ # Depends on the frontend install because the .ts/.tsx half of the rule set
+ # runs as an oxlint plugin. Without it the TS engine warns and skips, which
+ # would leave the frontend silently unchecked on CI.
+ deps: [":frontend:install"]
+ cmds:
+ - node scripts/lint/comment-lint.mjs {{.CLI_ARGS}}
+
+ comment-lint:branch:
+ desc: "Check comment quality on everything this branch adds over its base"
+ summary: |
+ Like `task comment-lint`, but scoped to the whole branch rather than to
+ uncommitted work, so it still reports after you commit.
+
+ Exists as its own task because passing `-- --since origin/main` through Task
+ is not portable: with the npm build of Task the launcher is a PowerShell
+ script, and PowerShell strips the `--` before Task sees it, leaving Task to
+ print its own usage.
+
+ Override the base with BASE=.
+ vars:
+ BASE: '{{.BASE | default "origin/main"}}'
+ deps: [":frontend:install"]
+ cmds:
+ - node scripts/lint/comment-lint.mjs --since {{.BASE}}
+
+ comment-lint:ci:
+ desc: "Comment gate as CI runs it: fixture corpus, then the diff"
+ summary: |
+ The corpus checks the rules themselves rather than the code under review, so
+ it belongs on CI and not on every local commit. Run this before changing a
+ rule, and let CI run it on every pull request.
+ deps: [":frontend:install"]
+ cmds:
+ - node scripts/lint/comment-lint.mjs --selftest
+ - node scripts/lint/comment-lint.mjs {{.CLI_ARGS}}
+
+ comment-lint:hook:
+ desc: "Comment gate for the editor hook: everything this turn changed"
+ summary: |
+ Same scope as `task comment-lint`, kept as its own name so the hook has a
+ stable entry point and the taskfile shows every way the linter is invoked.
+
+ Not in the frontend-install dependency chain on purpose: this runs at the end
+ of every turn, so it stays as short as it can be. If oxlint is missing the TS
+ half warns and skips.
+ cmds:
+ - node scripts/lint/comment-lint.mjs
+
+ comment-lint:all:
+ desc: "Report every comment finding in the tree (never fails)"
+ deps: [":frontend:install"]
+ cmds:
+ - node scripts/lint/comment-lint.mjs --all
+
+ comment-lint:selftest:
+ desc: "Check both comment-lint engines against the fixture corpus"
+ deps: [":frontend:install"]
+ cmds:
+ - node scripts/lint/comment-lint.mjs --selftest
+
gitleaks-bin:
internal: true
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
diff --git a/AGENTS.md b/AGENTS.md
index 7383ab3c3d..88543ca3c8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,6 +21,43 @@ Task `desc:` fields should describe **what** the task does, not **how** it does
- `task docker:build` — build standard Docker image
- `task docker:up` — start Docker compose stack
+## Comments
+
+A comment must carry information the code cannot. If a reader could derive it from the code in front of them, delete it.
+
+Comment the current state. Not what the code used to do, not what changed, not why it changed: git holds that. Where history explains the shape, state the reason instead, so "this used to reimplement the modal internals" becomes "thin wrapper over the shared Modal: duplicating its portal and focus trap is how dialogs drift apart". Future state goes in a TODO with an issue.
+
+Write a comment when it does one of these four jobs:
+
+- **Contract.** What a caller must know that the signature cannot say: preconditions, invariants, units, ownership and lifetime, thread-safety, error semantics, side effects. Document the contract of everything a caller outside the file can reach, and nothing else. Goes on the type/method/module as Javadoc, JSDoc, or a docstring.
+- **Why.** The constraint the code satisfies, the bug it avoids, the alternative rejected and the reason.
+- **Hazard.** "Must stay in sync with X", "order matters because Y", "do not remove, it prevents Z".
+- **Map.** A short orientation at the top of a genuinely complex file: what it owns, and what it deliberately does not.
+
+Never write:
+
+- A comment that restates the next line. `// Handle drag start` above `handleDragStart` is noise.
+- Section banners or position markers: `// --- Types ---`, `// Helpers`, `// =====`.
+- Step narration in a function body (`// Step 1:`, `// Then we`). If the steps need labels they need names: extract functions. Numbering a genuinely numbered thing, like a wizard step, is fine.
+- Commented-out code. Delete it.
+- Doc tags that restate the signature. `@param blob - The blob` says nothing; omit the tag rather than pad it.
+- Docs on self-explanatory members with no constraint to state.
+
+Two tests before keeping a comment:
+
+- **Delete it.** Is any information lost? If not, it stays deleted.
+- **Could a name carry it instead?** A better identifier, an extracted function, or a named constant beats a comment. Prefer the code change.
+
+A comment at the end of a line usually decodes that line, and that is worth keeping: `{0x25, 0x50} // "%PDF"`, `50L * 1024 * 1024 // 50 MB`. The rules that compare a comment against the code below it do not apply there, but a trailing TODO or a trailing bit of history is judged like any other.
+
+A reference is supplementary, never load-bearing: the comment must survive deleting it. `// See #1234` is a dead end; `// saving first loses every annotation (#6865)` is not. Prefer a spec (`RFC 3161`) or CVE where one applies.
+
+A TODO needs an issue, not an owner: `// TODO(#1234): re-enable the gate once account syncing lands`. If it is not worth an issue, it is not worth a TODO. A question is not a TODO.
+
+A comment block over ~12 lines outside a file or type header usually means the code needs restructuring, or that the prose is product documentation and belongs in the docs repo.
+
+`task comment-lint` checks the mechanical part of this on the lines you add, and runs inside `task pre-commit`. Reasoning, worked examples and the linter's own rules: @devGuide/CODE_COMMENTS.md
+
## Common Development Commands
### Build and Test
@@ -70,7 +107,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
- Avoid nested functions and nested classes unless the language construct requires them.
- Prefer composition to inheritance when combining concepts.
- Avoid speculative abstractions. Add a layer only when it removes real duplication or clarifies lifecycle.
-- Add comments sparingly and only when they explain non-obvious intent.
+- Comments follow the repo-wide rules in the "Comments" section above.
#### Python Typing and Models
- Deserialize into Pydantic models as early as possible.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 65fc4bc262..371630193b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -42,6 +42,7 @@ Please make sure your Pull Request adheres to the following guidelines:
- Keep commits atomic. One commit should contain one change. If you want to make multiple changes, submit multiple Pull Requests.
- Commits should be clear, concise, and easy to understand.
- References to the Issue number in the Pull Request and/or Commit message.
+- Every comment in the diff should say something the code does not. See [Code comments](devGuide/CODE_COMMENTS.md); `task comment-lint` checks the mechanical part.
## Translations
diff --git a/Taskfile.yml b/Taskfile.yml
index e364242f1f..eaec7dec92 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -266,6 +266,20 @@ tasks:
cmds:
- task: frontend:lint
- task: engine:lint
+ - task: comment-lint
+
+ comment-lint:
+ desc: "Check comment quality on the lines this branch adds"
+ aliases: [comments]
+ cmds:
+ - task: pre-commit:comment-lint
+ vars: { CLI_ARGS: '{{.CLI_ARGS}}' }
+
+ comment-lint:branch:
+ desc: "Check comment quality on everything this branch adds over its base"
+ cmds:
+ - task: pre-commit:comment-lint:branch
+ vars: { BASE: '{{.BASE}}' }
fix:
desc: "Auto-fix all components"
diff --git a/devGuide/CODE_COMMENTS.md b/devGuide/CODE_COMMENTS.md
new file mode 100644
index 0000000000..739744142f
--- /dev/null
+++ b/devGuide/CODE_COMMENTS.md
@@ -0,0 +1,232 @@
+# Code comments
+
+A comment must carry information the code cannot. If a reader could derive it from
+the code in front of them, delete it: a redundant comment still has to be
+maintained, will eventually contradict the code, and dilutes the comments that
+matter.
+
+The operative rules are in `AGENTS.md`, kept short so they stay in an agent's
+context. This document is the reasoning and the worked examples behind them, plus
+how to run the linter.
+
+## Comment the current state
+
+Describe the code as it is. Not what it used to be, not what changed, not why it
+changed. A comment that narrates history is stale the moment the next change
+lands, and git already holds that record.
+
+When you know the history and it explains the shape of the code, the useful half is
+the reason, not the sequence. State the reason:
+
+```java
+// Don't:
+// This used to reimplement the modal internals, which is how the procurement
+// dialogs drifted from the billing ones.
+
+// Do:
+// Thin wrapper over the shared Modal: duplicating its portal and focus trap is
+// how dialogs drift apart.
+```
+
+Future state is the exception, and it belongs in a TODO with an issue.
+
+## The four jobs
+
+**Contract.** What a caller must know that the signature cannot say:
+preconditions, invariants, units, ownership and lifetime, thread-safety, error
+semantics, side effects.
+
+The bound is the surface, not the volume: document the contract of everything a
+caller outside the file can reach, and nothing else. Inside that surface say
+whatever a caller needs; outside it a comment earns its place on the same terms as
+any other.
+
+```java
+/**
+ * Authority on which filesystem locations a policy may read or write. Fail-closed
+ * in order: denied entirely under the saas profile; Stirling's own config dir is
+ * always rejected; the path must resolve within policies.allowedFolderRoots.
+ *
+ *
Compared after normalisation so {@code ..} cannot escape a root. Symlink
+ * escape is not defended: an operator who roots an allowlist on a symlink to a
+ * sensitive location is trusted.
+ */
+```
+
+**Why.** The constraint the code satisfies, the bug it avoids, the alternative
+rejected and the reason.
+
+```java
+// whenComplete runs on the worker thread after the run finishes, so the
+// terminal event never races the step events.
+handle.completion()
+```
+
+A reference is supplementary, never load-bearing: the comment must survive
+deleting it. `// See #1234` is a dead end.
+
+```java
+// flatten() reads the annotation list that save() clears, so saving first loses
+// every annotation (#6865).
+document.flatten(annotations);
+```
+
+Prefer a spec (`RFC 3161`, `ISO 4217`) or a CVE where one applies. Both are
+immutable; a ticket can be closed, moved or made private.
+
+**Hazard.** "Must stay in sync with X." "Order matters because Y." "Do not remove,
+it prevents Z."
+
+**Map.** A short orientation at the head of a genuinely complex file: what it owns,
+and what it deliberately does not.
+
+## The test that decides it
+
+A comment earns its place when it sits at a different level of detail than the line
+below it: lower, stating a precise fact the code implies but does not say, or
+higher, giving intent a reader would otherwise assemble from ten lines.
+Same-altitude is the definition of redundant.
+
+- **Delete it.** Is any information lost? If not, it stays deleted.
+- **Could a name carry it instead?** A better identifier, an extracted function or
+ a named constant beats a comment. Prefer the code change.
+
+## What not to write
+
+| Don't | Instead |
+| --- | --- |
+| `// Handle drag start` above `handleDragStart` | Nothing. The name already says it. |
+| `// ─── Types ───`, `// Helpers`, `// ====` | If a file needs internal signposting, split the file. |
+| `// Step 1:` narrating a function body | Extract functions. If the steps need labels they need names. |
+| `// No longer needed`, `// Previously this used X` | State why the code is as it is now, or nothing. |
+| Commented-out code | Delete it. Git remembers. |
+| `@param blob - The blob to download` | Omit the tag rather than pad it. |
+| Docs on a self-explanatory member | Nothing, unless there is a real constraint to state. |
+
+Step numbering is fine where it labels a genuinely numbered thing, such as a wizard
+step or a step in a written test procedure. It is narration when it numbers the
+lines of one function.
+
+## Comments at the end of a line
+
+A trailing comment usually does a different job from one above the code: it decodes
+the line it sits on. Those are worth keeping, and the linter leaves them alone.
+
+```java
+byte[] pdfBytes = {0x25, 0x50, 0x44, 0x46}; // "%PDF"
+long maxAttachmentSize = 50L * 1024 * 1024; // 50 MB
+double buffer = 0.10; // 10% headroom
+default -> toBytes(value, 2); // MB
+```
+
+Each overlaps in words with the code and each adds the interpretation the code
+leaves implicit, which is the lower-altitude case the test above asks for. So
+`CMT001` does not judge trailing comments; on this codebase it would have been
+wrong about roughly six in seven of them.
+
+What still applies is anything that does not depend on the code below: a trailing
+`// TODO fix this` is as unowned as one on its own line, and a trailing
+`// this used to run before the flush` narrates history wherever it sits.
+
+A comment block over about 12 lines, outside a file or type header, is usually a
+sign the code needs restructuring. If it is genuinely product documentation, it
+belongs in the docs repo.
+
+## TODOs
+
+A TODO needs an issue, because an issue is the only part that will close it:
+
+```java
+// TODO(#1234): re-enable the checkout gate once account syncing lands
+```
+
+An owner is not a substitute: a username goes stale when someone changes team and
+means nothing to an outside contributor. If the work is not worth an issue it is
+not worth a TODO, and the options are to do it now or leave the code alone. A
+question is not a TODO.
+
+## Per language
+
+**Java.** Google Java Style, which this repo already formats to. Its §7.3.1
+exception applies: omit Javadoc on a self-explanatory member where there is
+genuinely nothing to add, but do not cite it to skip something a reader needs.
+Summary fragments are noun or verb phrases, not sentences starting "This method
+returns".
+
+**TypeScript.** JSDoc on the `@app/*` seams, exported hooks, and anything crossing
+a layer boundary. No `@param`/`@returns` that restates a typed signature. JSX
+comments follow the same rules as any other.
+
+**Python.** Docstrings on modules, public functions and Pydantic models where the
+contract is not obvious from the type.
+
+## The linter
+
+```bash
+task comment-lint # what the working tree adds over HEAD
+task comment-lint:branch # what the branch adds over origin/main (BASE= to change)
+task pre-commit:comment-lint:ci # the fixture corpus, then the diff
+```
+
+`comment-lint` is the pre-commit question, so it reports nothing once you have
+committed; on a CI pull request it compares against the target branch via
+`GITHUB_BASE_REF`. `comment-lint:branch` is the review question. The corpus checks
+the rules themselves rather than the code under review, so it runs on CI and before
+a rule change, not on every local commit.
+
+`task comment-lint` also runs inside `task pre-commit`, and as a Claude Code `Stop`
+hook, so an agent is told before it finishes a turn and fixes the comment inside
+that turn. Stop rather than per file write: a run costs the same for one file as for
+twenty-five, and half of all writes in a turn go to a file already written in it.
+
+Findings are scoped to comment text that is new, not to lines git calls new, so
+reindenting or moving code does not resurface comments you did not write.
+
+The rules are the `RULES` object in
+[`scripts/lint/comment-rules.mjs`](../scripts/lint/comment-rules.mjs); the exact
+condition for each is the predicate of the same name in that file, with the
+readings it deliberately excludes beside it.
+
+**Every rule blocks.** A rule that only warns is a rule nobody acts on. So a
+finding you believe is wrong is a bug in the rule, not something to live with:
+narrow the rule, or mark the line and say why.
+
+Every comment form the repo writes is covered: `//` and `/* */`, Javadoc and JSDoc,
+JSX comments, `#`, and Python docstrings. `CMT007` reads all three parameter
+conventions in use here, Javadoc/JSDoc `@param`, Sphinx `:param name:` and Google
+`name: description` under `Args:`.
+
+Two engines, one rule set. `.ts`/`.tsx`/`.mjs` go to an oxlint JS plugin, so
+comments come from the parser: a `//` inside a string is not a comment, and JSX
+`{/* … */}` is. `.java`/`.py` go to a line scanner. Neither reads the other's
+files, so they cannot disagree about one file. `scripts/lint/fixtures/` is the
+corpus that keeps them meaning the same thing.
+
+### When a finding is wrong
+
+Name the rule on the line above:
+
+```ts
+// comment-lint-allow: CMT002
+// ─── kept deliberately, because ───
+```
+
+There is no form that disables every rule, and the directive has to earn its
+place. `CMT008` reports one that names something which is not a rule, and one that
+silences nothing, so a typo does not read as a suppression and a stale
+suppression does not sit there blinding the line. The whole comment must be the
+directive; prose that mentions the syntax is just prose.
+
+If you reach for this more than occasionally the rule is wrong: fix it in
+`comment-rules.mjs` and update the fixture corpus in the same commit, so the diff
+shows what moved.
+
+### The existing backlog
+
+`task pre-commit:comment-lint:all` reports the whole tree and never fails. There is
+a standing backlog being cleared by directory; diff scoping is what keeps it off
+whoever touches a file first.
+
+To turn the editor hook off, put `{ "env": { "COMMENT_LINT_HOOK": "0" } }` in
+`.claude/settings.local.json`. The commit-time gate still applies, so you lose the
+early warning rather than the check.
diff --git a/devGuide/README.md b/devGuide/README.md
index 5e8486f013..2ddaff63c3 100644
--- a/devGuide/README.md
+++ b/devGuide/README.md
@@ -8,6 +8,7 @@ This directory contains all development-related documentation for Stirling PDF.
- **[DeveloperGuide.md](../DeveloperGuide.md)** - Main developer setup and architecture guide (in repo root)
- **[Taskfile.yml](../Taskfile.yml)** - Unified task runner for all build/dev/test/lint commands
- **[EXCEPTION_HANDLING_GUIDE.md](./EXCEPTION_HANDLING_GUIDE.md)** - Exception handling patterns and i18n best practices
+- **[CODE_COMMENTS.md](./CODE_COMMENTS.md)** - What a comment is for, what not to write, and the `task comment-lint` rules
- **[HowToAddNewLanguage.md](./HowToAddNewLanguage.md)** - Internationalization and translation guide
- **[STORAGE_ENCRYPTION_AT_REST.md](./STORAGE_ENCRYPTION_AT_REST.md)** - Encryption at rest for stored files: key setup, migration, revocation, rotation
diff --git a/frontend/oxlint.comments.config.ts b/frontend/oxlint.comments.config.ts
new file mode 100644
index 0000000000..a3a238893c
--- /dev/null
+++ b/frontend/oxlint.comments.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig } from "oxlint";
+
+// Comment-quality rules only, kept out of oxlint.config.ts on purpose.
+//
+// The main config is run with --max-warnings=0 over the whole frontend, and the
+// existing tree still has several hundred findings from these rules. Enabling
+// them there would fail every build until the cleanup lands. So they live here
+// and are driven by `task comment-lint`, which scopes findings to the lines a
+// branch actually added.
+//
+// Fold this into oxlint.config.ts once the tree is clean; that is the step that
+// also buys IDE squiggles, and the point at which this file goes away.
+
+export default defineConfig({
+ jsPlugins: ["../scripts/lint/comment-lint-oxlint-plugin.mjs"],
+ categories: { correctness: "off" },
+ ignorePatterns: [
+ "dist",
+ "dist-portal",
+ "node_modules",
+ "playwright-report",
+ "storybook-static",
+ "test-results",
+ "editor/dist",
+ "editor/public",
+ "editor/src-tauri",
+ ],
+ rules: {
+ "comments/quality": "error",
+ },
+});
diff --git a/scripts/lint/comment-lint-hook.mjs b/scripts/lint/comment-lint-hook.mjs
new file mode 100644
index 0000000000..af573c6a91
--- /dev/null
+++ b/scripts/lint/comment-lint-hook.mjs
@@ -0,0 +1,119 @@
+#!/usr/bin/env node
+// Claude Code Stop hook: check the comments this turn wrote, before it ends.
+//
+// Wired up by .claude/settings.json. Exit 2 stops Claude from finishing and shows
+// stderr to it, so the comment is fixed inside the same turn and never reaches a
+// diff, a CI run, or a reviewer.
+//
+// Stop rather than PostToolUse, measured over 605 real turns: a run costs the same
+// whether it looks at one file or twenty-five, because node startup and one git
+// diff dominate and the TS engine is spawned once for the batch. Per write it was
+// 40 minutes of hook latency across those turns, and up to 35 seconds inside a
+// single heavy one; per turn it is under a second, flat. Half of all writes were
+// to a file already written that turn, so most of that work was repeated.
+//
+// To turn it off, set COMMENT_LINT_HOOK=0. Claude Code has no way to disable one
+// hook (only disableAllHooks, which turns off everyone's), so the opt-out lives
+// here instead. Per developer, in .claude/settings.local.json:
+//
+// { "env": { "COMMENT_LINT_HOOK": "0" } }
+//
+// The commit-time gate still applies either way, so opting out costs you the
+// early warning, not the check.
+
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO = resolve(HERE, "..", "..");
+
+// Invoked through Task so the taskfile stays the one place that defines how the
+// linter is called.
+const TASK_NAME = "pre-commit:comment-lint:hook";
+const OFF = new Set(["0", "off", "false", "no"]);
+
+// The linter's own exit codes: 1 when it found something, 2 when its engine could
+// not run. The hook's codes mean different things, so they are mapped explicitly.
+const FOUND = 1;
+const ENGINE_BROKEN = 2;
+
+if (OFF.has((process.env.COMMENT_LINT_HOOK ?? "").toLowerCase())) process.exit(0);
+
+const payload = readStdin();
+
+// Blocking the stop puts Claude back to work, which ends in another stop and
+// another chance to block. Claude Code sets this flag once a Stop hook has
+// already blocked in this turn, so a rule the agent cannot satisfy costs one
+// extra attempt rather than looping. The commit gate still catches whatever
+// survives.
+if (payload?.stop_hook_active) process.exit(0);
+
+const result = run();
+if (result.status === 0) process.exit(0);
+
+if (result.status === ENGINE_BROKEN) {
+ process.stderr.write("comment-lint could not run, so comments in this turn were not checked.\n");
+ process.exit(1);
+}
+
+// Anything else is Task itself failing, which means the check did not happen.
+if (result.status !== FOUND) {
+ process.stderr.write(`comment-lint did not run (task exit ${result.status}), so comments in this turn were not checked.\n`);
+ process.exit(1);
+}
+
+// The linter's own report already names the file, line, rule and the standard, so
+// it is passed through rather than rewritten.
+process.stderr.write(`${result.output.trim()}\n\nFix these before finishing.\n`);
+process.exit(2);
+
+function readStdin() {
+ try {
+ return JSON.parse(readFileSync(0, "utf8"));
+ } catch {
+ return null;
+ }
+}
+
+// `task` on PATH is a shell wrapper that boots Node to launch the Go binary the
+// npm package already ships, which costs about 400ms. Prefer the binary; fall
+// back to the wrapper when the layout is not one of the ones probed, or when Task
+// came from somewhere else entirely.
+function taskCommand() {
+ const nodeDir = dirname(process.execPath);
+ const exe = process.platform === "win32" ? "task.exe" : "task";
+ const candidates = [
+ resolve(nodeDir, "node_modules/@go-task/cli/bin", exe),
+ resolve(nodeDir, "../lib/node_modules/@go-task/cli/bin", exe),
+ resolve(REPO, "node_modules/@go-task/cli/bin", exe),
+ ];
+ for (const candidate of candidates) {
+ if (existsSync(candidate)) return { command: candidate, shell: false };
+ }
+ return { command: process.platform === "win32" ? "task.cmd" : "task", shell: process.platform === "win32" };
+}
+
+function run() {
+ const { command, shell } = taskCommand();
+ try {
+ // --output=interleaved because the root Taskfile sets `output: prefixed`,
+ // which would put the task name in front of every reported finding.
+ // --exit-code because Task otherwise reports its own 201 for any failed task,
+ // which hides whether the linter found something or could not run.
+ // Task's stderr is captured rather than inherited: it announces its own
+ // "Failed to run task" for any non-zero command, which would reach Claude
+ // alongside the findings and read as a tooling error.
+ const output = execFileSync(command, [TASK_NAME, "--silent", "--output=interleaved", "--exit-code"], {
+ cwd: REPO,
+ encoding: "utf8",
+ maxBuffer: 1 << 26,
+ shell,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ return { status: 0, output };
+ } catch (error) {
+ return { status: error.status ?? -1, output: error.stdout ?? "" };
+ }
+}
diff --git a/scripts/lint/comment-lint-oxlint-plugin.mjs b/scripts/lint/comment-lint-oxlint-plugin.mjs
new file mode 100644
index 0000000000..045b23518b
--- /dev/null
+++ b/scripts/lint/comment-lint-oxlint-plugin.mjs
@@ -0,0 +1,126 @@
+// oxlint JS plugin: the comment-quality rules for .ts and .tsx.
+//
+// This engine owns the frontend outright; comment-lint.mjs never scans TS. The
+// reason to run oxlint here rather than scan lines is that comment tokens come
+// from the parser, so a `//` inside a string or regex is not a comment, JSX
+// `{/* … */}` is, and positions are exact. Rule decisions themselves live in
+// comment-rules.mjs, shared with the Java/Python engine.
+//
+// Reported as one rule with the CMT id in the message, because oxlint config
+// severity is per rule name and every finding here shares one on/off switch.
+//
+// Enabled by frontend/oxlint.comments.config.ts. `context.report` needs
+// `node.range`; passing start/end throws.
+
+import { analyse, isTestPath, isGenerated, isExcludedPath, ruleLabel } from "./comment-rules.mjs";
+
+const comments = {
+ create(context) {
+ return {
+ "Program:exit"() {
+ const sourceCode = context.sourceCode;
+ const filename = context.filename ?? context.getFilename?.() ?? "";
+ if (isExcludedPath(filename)) return;
+
+ const text = sourceCode.text;
+ if (isGenerated(text)) return;
+
+ const lines = sourceCode.getLines();
+ const tokens = sourceCode.getAllComments();
+ if (tokens.length === 0) return;
+
+ const runs = groupIntoRuns(tokens, lines);
+ const findings = analyse({ lines, runs, isTestFile: isTestPath(filename) });
+
+ // Ranges come from the run entries rather than the enclosing token, so a
+ // finding on the eighth line of a doc block points at that line instead
+ // of at the opening `/**`.
+ const ranges = new Map();
+ for (const run of runs) {
+ for (const entry of run.lines) ranges.set(entry.line, entry.range);
+ }
+
+ for (const finding of findings) {
+ context.report({
+ message: `${ruleLabel(finding.rule)}: ${finding.detail}`,
+ node: { type: "Line", range: ranges.get(finding.line) ?? [0, 1] },
+ });
+ }
+ },
+ };
+ },
+};
+
+// Adjacent comment lines with no code between them form one run, which is the
+// unit the block-length and dead-code rules judge. A comment sharing its line
+// with code is a trailing note, not part of any run.
+function groupIntoRuns(tokens, lines) {
+ const runs = [];
+ let current = null;
+
+ for (const token of tokens) {
+ const entries = expand(token, lines);
+ if (entries.length === 0) continue;
+
+ const kind = token.type === "Line" ? "line" : token.value.startsWith("*") ? "doc" : "block";
+ const startLine = entries[0].line;
+ const trailing = entries[0].trailing === true;
+ const contiguous = !trailing && current && startLine === current.endLine + 1 && current.kind === kind && !current.trailing;
+
+ if (contiguous) {
+ current.lines.push(...entries);
+ current.endLine = entries[entries.length - 1].line;
+ continue;
+ }
+ current = { startLine, endLine: entries[entries.length - 1].line, kind, trailing, lines: entries };
+ runs.push(current);
+
+ // Code sits in front of a trailing comment, so nothing can continue it.
+ if (trailing) current = null;
+ }
+
+ return runs;
+}
+
+// One entry per physical line, with the leading `*` of a doc block stripped so
+// the rules see the prose rather than the box drawing around it. Each entry
+// carries its own source range so findings can be reported where they are.
+function expand(token, lines) {
+ const start = token.loc.start.line;
+ const column = token.loc.start.column + 1;
+ const before = (lines[start - 1] ?? "").slice(0, token.loc.start.column).trim();
+
+ // Code in front of the comment makes it a trailing note. Marked rather than
+ // dropped, so CMT004 and CMT009 still see it: a TODO is a TODO wherever it
+ // sits. The rules that compare a comment against the code below it stay out,
+ // because a trailing comment usually decodes the line it sits on.
+ //
+ // A block comment counts as trailing only when it also closes on that line.
+ // One that runs on has its bulk on lines of its own, so it is judged as the
+ // block it is.
+ const sameLine = token.loc.start.line === token.loc.end.line;
+ const trailing = before.length > 0 && !before.startsWith("{") && (token.type === "Line" || sameLine);
+
+ if (token.type === "Line") {
+ return [{ line: start, column, body: token.value, range: token.range, trailing }];
+ }
+
+ // token.value is the text between the delimiters, so it begins two chars in.
+ let offset = token.range[0] + 2;
+ return token.value.split("\n").map((raw, index) => {
+ const range = [offset, offset + Math.max(raw.length, 1)];
+ offset += raw.length + 1;
+ return {
+ line: start + index,
+ column: index === 0 ? column : 1,
+ body: raw.replace(/^\s*\*+/, "").trim(),
+ range,
+ trailing,
+ };
+ });
+}
+
+export default {
+ meta: { name: "comments" },
+ rules: { quality: comments },
+};
diff --git a/scripts/lint/comment-lint.mjs b/scripts/lint/comment-lint.mjs
new file mode 100644
index 0000000000..55b0cb4039
--- /dev/null
+++ b/scripts/lint/comment-lint.mjs
@@ -0,0 +1,743 @@
+#!/usr/bin/env node
+
+// comment-lint - the comment-quality gate. Standard: devGuide/CODE_COMMENTS.md
+//
+// Owns .java and engine .py directly, and delegates .ts/.tsx to oxlint (see
+// comment-lint-oxlint-plugin.mjs) so the frontend is judged against real comment
+// tokens rather than lines. Both paths share the rules in comment-rules.mjs, so
+// a finding means the same thing whichever engine produced it.
+//
+// node scripts/lint/comment-lint.mjs default: everything this
+// working tree adds over HEAD,
+// or over the target branch on CI
+// node scripts/lint/comment-lint.mjs --since main findings on lines this branch added
+// node scripts/lint/comment-lint.mjs --all whole tree, report only, never fails
+// node scripts/lint/comment-lint.mjs those files, every line
+// node scripts/lint/comment-lint.mjs --selftest run the fixture corpus
+// --quiet ...saying nothing unless it fails
+// node scripts/lint/comment-lint.mjs --json machine-readable findings
+//
+// Exits non-zero for any finding on a line in scope: every rule blocks, because a
+// warning is a finding nobody acts on. --all never fails, because the tree still
+// has a backlog; it is the mode for working through it.
+
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
+import { dirname, isAbsolute, join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ analyse,
+ commentBodiesOf,
+ normaliseComment,
+ isExcludedPath,
+ isGenerated,
+ isTestPath,
+ ruleLabel,
+ RULES,
+} from "./comment-rules.mjs";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const REPO = resolve(HERE, "..", "..");
+const FRONTEND = join(REPO, "frontend");
+
+// The Python this repo owns and formats: the engine service, plus the helper
+// scripts that pre-commit already runs ruff over. Vendored and sample .py
+// elsewhere in the tree is not ours to restyle.
+const JAVA = /\.java$/;
+const PYTHON = /^(engine|scripts|\.github\/scripts)\/.*\.py$/;
+
+// The oxlint engine parses plain JS as happily as TS, so the lint scripts and
+// build tooling are held to the same rules as the app.
+const TYPESCRIPT = /\.(tsx?|mts|cts|mjs|cjs|jsx?)$/;
+
+const FIXTURES_REL = "scripts/lint/fixtures/";
+
+// oxlint rejects any path containing "..", so it is always run from the repo
+// root and given repo-relative paths. Its config still lives under frontend/,
+// which is what makes `oxlint` and the plugin resolvable from there.
+const OXLINT_BIN = "frontend/node_modules/oxlint/bin/oxlint";
+const OXLINT_CONFIG = "frontend/oxlint.comments.config.ts";
+
+// Windows caps a command line near 32k characters, which a whole-tree file list
+// exceeds by a wide margin. Unbatched it dies with ENAMETOOLONG, and silently:
+// oxlint exits non-zero normally, so the error reads as "no findings".
+const ARGV_BUDGET = 24_000;
+
+// Every module constant lives in this block. The top-level run below starts
+// before any function body is reached, so a `const` declared further down is
+// still in its temporal dead zone when the first call touches it.
+const baseComments = new Map();
+
+// A Java char literal, which is the only thing a single quote can legitimately
+// open: one character, or one escape. An apostrophe in prose never matches, so
+// `/** The approver's team ... */` keeps its closing delimiter. Without this the
+// apostrophe opened a literal that never closed, the `*/` was blanked away, and
+// the scanner read the next 47 lines of code as one comment.
+const CHAR_LITERAL = /^'(\\[btnfr'"\\0]|\\u[0-9a-fA-F]{4}|[^'\\])'/;
+
+// A docstring opens the line, optionally behind a string prefix. Anything with
+// code in front of the quotes is a value, not documentation.
+const DOCSTRING_OPEN = /^[rbuf]{0,2}("""|''')/;
+
+const argv = process.argv.slice(2);
+const flags = new Set(argv.filter((a) => a.startsWith("--")));
+const positional = argv.filter((a) => !a.startsWith("--") && !isFlagValue(a));
+
+if (flags.has("--selftest")) process.exit(runSelfTest());
+if (flags.has("--help")) {
+ process.stdout.write(
+ readFileSync(fileURLToPath(import.meta.url), "utf8")
+ .split("\n")
+ .slice(1, 22)
+ .join("\n")
+ .replace(/^\/\/ ?/gm, "") + "\n",
+ );
+ process.exit(0);
+}
+
+const scope = resolveScope();
+const findings = collect(scope);
+process.exit(publish(findings, scope));
+
+// A "scope" is the set of files to look at plus, when the run is diff-based, the
+// set of lines that are new. Reporting a legacy finding in a file someone merely
+// touched is how a gate like this gets switched off, so diff runs filter by line.
+
+function resolveScope() {
+ if (flags.has("--all")) return { mode: "all", files: trackedFiles(), added: null };
+
+ const paths = positional.map(toRepoPath);
+
+ // Paths plus --since is how the editor hook asks about one file: lint it, but
+ // only the lines this session actually wrote.
+ if (paths.length > 0 && flags.has("--since")) {
+ const ref = mergeBase(flagValue("--since"));
+ return narrow(diffScope(["diff", "--unified=0", "--no-color", ref, "--", ...paths], ref, paths), paths);
+ }
+ if (paths.length > 0) return { mode: "paths", files: paths, added: null };
+
+ if (flags.has("--since")) {
+ const ref = mergeBase(flagValue("--since"));
+ return diffScope(["diff", "--unified=0", "--no-color", ref], ref);
+ }
+
+ // Always a working-tree comparison, never `--cached`. Findings are read from
+ // the file on disk, so diffing the index instead would pair index line numbers
+ // with working-tree content and silently mismatch once the two differ.
+ // CI knows the target branch; a developer running this before a commit does not.
+ const base = process.env.GITHUB_BASE_REF;
+ const ref = mergeBase(base ? `origin/${base}` : "HEAD");
+ return diffScope(["diff", "--unified=0", "--no-color", ref], ref);
+}
+
+function mergeBase(ref) {
+ try {
+ return git(["merge-base", "HEAD", ref]).trim();
+ } catch {
+ // A shallow clone or a missing remote ref: compare against the ref itself.
+ return ref;
+ }
+}
+
+function diffScope(args, base, paths = null) {
+ let diff;
+ try {
+ diff = git(args);
+ } catch (error) {
+ // A shallow clone, a detached CI checkout, or a base branch that was never
+ // fetched. Degrading to report-only beats failing a build over plumbing.
+ warn(`could not resolve a diff (${firstLine(error.stderr ?? error.message)}), so nothing was checked.`);
+ return { mode: "all", files: [], added: null };
+ }
+
+ const added = new Map();
+ let file = null;
+ for (const line of diff.split("\n")) {
+ if (line.startsWith("+++ ")) {
+ const path = line.slice(4).replace(/^b\//, "").trim();
+ file = path === "/dev/null" ? null : path;
+ if (file) added.set(file, new Set());
+ continue;
+ }
+ if (!file || !line.startsWith("@@")) continue;
+ const hunk = /\+(\d+)(?:,(\d+))?/.exec(line);
+ if (!hunk) continue;
+ const start = Number(hunk[1]);
+ const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
+ for (let i = 0; i < count; i++) added.get(file).add(start + i);
+ }
+
+ // git diff never mentions an untracked file, so a brand new one would be waved
+ // through entirely. Every line of one is new. Listing every untracked file in
+ // the repo costs about as much as the diff, so when the caller already named the
+ // paths, only those are asked about.
+ for (const file of untrackedFiles(paths)) {
+ if (added.has(file)) continue;
+ added.set(file, allLinesOf(file));
+ }
+
+ return { mode: "diff", files: [...added.keys()], added, base };
+}
+
+function untrackedFiles(paths = null) {
+ const args = ["ls-files", "--others", "--exclude-standard"];
+ if (paths) args.push("--", ...paths);
+ return git(args).split("\n").filter(Boolean);
+}
+
+function allLinesOf(file) {
+ const path = insideRepo(file);
+ if (!path || !existsSync(path)) return new Set();
+ const total = readFileSync(path, "utf8").split(/\r?\n/).length;
+ return new Set(Array.from({ length: total }, (_, i) => i + 1));
+}
+
+function narrow(scope, paths) {
+ // A diff that could not be resolved already returned a degraded scope with no
+ // added map. Pass it straight through: an empty map here would read as a clean
+ // pass rather than as a run that checked nothing.
+ if (!scope.added) return scope;
+
+ const wanted = new Set(paths);
+ const added = new Map([...scope.added].filter(([file]) => wanted.has(file)));
+ return { mode: "diff", files: [...added.keys()], added, base: scope.base };
+}
+
+function trackedFiles() {
+ return git(["ls-files"]).split("\n").filter(Boolean);
+}
+
+function toRepoPath(path) {
+ return relative(REPO, resolve(process.cwd(), path)).replace(/\\/g, "/");
+}
+
+function collect(scope) {
+ const selected = scope.files.map((f) => f.replace(/\\/g, "/")).filter(isLintable);
+
+ // A path named on the command line and then dropped has to be said out loud.
+ // Reporting "clean" for a file this never opened is the failure mode the rest
+ // of this script works to avoid.
+ if (scope.mode === "paths") {
+ for (const file of scope.files) {
+ if (!selected.includes(file.replace(/\\/g, "/"))) warn(`skipped ${file}: not a lintable file inside the repo.`);
+ }
+ }
+ const results = [];
+
+ for (const file of selected.filter((f) => JAVA.test(f) || PYTHON.test(f))) {
+ results.push(...lintLineBased(file));
+ }
+
+ const ts = selected.filter((f) => TYPESCRIPT.test(f));
+ if (ts.length > 0) results.push(...lintTypeScript(ts));
+
+ if (scope.added) {
+ const onAddedLine = results.filter((r) => scope.added.get(r.file)?.has(r.line));
+ return onAddedLine.filter((r) => !existedAtBase(r, scope.base));
+ }
+ return results;
+}
+
+// git marks a reindented or moved line as added, so line membership alone reports
+// comments nobody wrote. A finding only counts if its comment text is not already
+// in the file at the base.
+//
+// Cost is one `git show` per file, memoised. It gets one case wrong: adding a
+// further copy of an already-duplicated comment reads as pre-existing. That is the
+// right way round for a blocking rule.
+
+function existedAtBase(finding, base) {
+ if (!base) return false;
+ // Findings from the oxlint plugin arrive without their comment text, because
+ // they cross a process boundary as a message string. Recover it from the file
+ // on disk at the reported line, which is the same text the rule judged.
+ const body = finding.body ?? currentLineBody(finding);
+ if (!body) return false;
+ const key = `${base}:${finding.file}`;
+ if (!baseComments.has(key)) {
+ let source = "";
+ try {
+ source = git(["show", key]);
+ } catch {
+ // Not in the base at all, so the whole file is new.
+ }
+ baseComments.set(key, commentBodiesOf(source));
+ }
+ return baseComments.get(key).has(body);
+}
+
+function currentLineBody(finding) {
+ try {
+ const line = readFileSync(insideRepo(finding.file), "utf8").split(/\r?\n/)[finding.line - 1];
+ return line === undefined ? "" : normaliseComment(line);
+ } catch {
+ return "";
+ }
+}
+
+// Everything this tool reads is named by git or by a developer on the command
+// line, so a path outside the repo is a mistake rather than an attack. Resolving
+// through here keeps the contract true: git show and git diff cannot answer for a
+// path outside the work tree, so escaping it only produces confusing output.
+function insideRepo(file) {
+ const target = resolve(REPO, file);
+ const rel = relative(REPO, target);
+ if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return "";
+ return target;
+}
+
+function isLintable(file) {
+ if (!insideRepo(file)) return false;
+ if (isExcludedPath(file)) return false;
+
+ // The corpus is deliberately full of findings. Only the selftest reads it,
+ // and it does so by path rather than through this filter.
+ if (file.startsWith(FIXTURES_REL)) return false;
+ if (!JAVA.test(file) && !PYTHON.test(file) && !TYPESCRIPT.test(file)) return false;
+ return existsSync(insideRepo(file));
+}
+
+function lintLineBased(file) {
+ const source = readFileSync(insideRepo(file), "utf8");
+ if (isGenerated(source)) return [];
+ const lines = source.split(/\r?\n/);
+ const runs = readRuns(lines, PYTHON.test(file) ? "py" : "java");
+ return analyse({ lines, runs, isTestFile: isTestPath(file) }).map((f) => ({ ...f, file }));
+}
+
+// Groups comment lines into runs, the same shape the oxlint plugin builds from
+// parser tokens. String literals are blanked first so a `//` inside one is not
+// mistaken for a comment; without that, every URL in a string became a finding.
+function readRuns(lines, language) {
+ const runs = [];
+ let current = null;
+ let inBlock = false;
+ let docstring = null;
+
+ const push = (index, column, body, kind, trailing = false) => {
+ const line = index + 1;
+ if (!trailing && current && current.endLine === line - 1 && current.kind === kind && !current.trailing) {
+ current.lines.push({ line, column, body });
+ current.endLine = line;
+ return;
+ }
+ current = { startLine: line, endLine: line, kind, trailing, lines: [{ line, column, body }] };
+ runs.push(current);
+ if (trailing) current = null;
+ };
+
+ for (let i = 0; i < lines.length; i++) {
+ const raw = lines[i];
+ const text = blankStrings(raw, language);
+ const trimmed = text.trim();
+ const column = raw.length - raw.trimStart().length + 1;
+
+ if (inBlock) {
+ push(i, column, stripDocPrefix(raw), "doc");
+ if (trimmed.includes("*/")) inBlock = false;
+ continue;
+ }
+ if (trimmed.length === 0) {
+ current = null;
+ continue;
+ }
+ if (language === "py") {
+ // Every triple-quoted string is tracked, not just the documenting ones.
+ // A template assigned to a constant opens mid-line and so is not
+ // documentation, but its closing delimiter sits alone on a line and reads
+ // exactly like an opener. Ignoring those strings desynchronised the
+ // scanner for the rest of the file, and 35 lines of ordinary code were
+ // reported as commented-out.
+ if (docstring) {
+ if (docstring.isDoc) push(i, column, stripDocstringDelimiters(raw), "doc");
+ else current = null;
+ if (raw.includes(docstring.delimiter)) docstring = null;
+ continue;
+ }
+ if (trimmed.startsWith("#")) {
+ push(i, column, raw.trim().replace(/^#+/, ""), "line");
+ continue;
+ }
+ const hashAt = raw.indexOf("#");
+ if (hashAt > 0 && !/["']/.test(raw.slice(0, hashAt))) {
+ const body = raw.slice(hashAt + 1).trim();
+ if (body.length > 0) {
+ push(i, hashAt + 1, body, "line", true);
+ continue;
+ }
+ }
+ const quoted = tripleQuoted(trimmed);
+ if (quoted) {
+ if (quoted.isDoc) push(i, column, stripDocstringDelimiters(raw), "doc");
+ else current = null;
+ if (!quoted.closes) docstring = quoted;
+ continue;
+ }
+ current = null;
+ continue;
+ }
+ if (trimmed.startsWith("/*")) {
+ push(i, column, stripDocPrefix(raw), trimmed.startsWith("/**") ? "doc" : "block");
+ if (!trimmed.includes("*/")) inBlock = true;
+ continue;
+ }
+ if (trimmed.startsWith("//")) {
+ push(i, column, raw.trim().replace(/^\/\/+/, ""), "line");
+ continue;
+ }
+
+ // Code first, then a comment. blankStrings has already neutralised any `//`
+ // inside a string literal, so this index is a real comment marker. A block
+ // comment counts here only when it also closes on this line, matching the
+ // oxlint engine: one that runs on has its bulk on lines of its own.
+ const trailingLine = text.indexOf("//");
+ const trailingBlock = text.indexOf("/*");
+ const at =
+ trailingLine > 0 ? trailingLine : trailingBlock > 0 && text.includes("*/", trailingBlock) ? trailingBlock : -1;
+ if (at > 0) {
+ const body = raw
+ .slice(at + 2)
+ .replace(/\*\/.*$/, "")
+ .trim();
+ if (body.length > 0) {
+ push(i, at + 1, body, "line", true);
+ continue;
+ }
+ }
+ current = null;
+ }
+
+ return runs;
+}
+
+// The prose inside a docstring line, with the triple quotes and any string
+// prefix taken off so the rules see what a reader sees.
+// Where a triple-quoted string starts on this line, and whether it counts as
+// documentation. It documents when the quotes open the line, allowing a string
+// prefix; a template assigned to a constant opens mid-line and is data, and
+// reading JSON as prose would judge its keys as comments. An odd number of
+// delimiters means the string continues onto the next line.
+function tripleQuoted(trimmed) {
+ const found = /("""|''')/.exec(trimmed);
+ if (!found) return null;
+ const delimiter = found[1];
+ const occurrences = trimmed.split(delimiter).length - 1;
+ return {
+ delimiter,
+ isDoc: DOCSTRING_OPEN.test(trimmed),
+ closes: occurrences % 2 === 0,
+ };
+}
+
+function stripDocstringDelimiters(raw) {
+ return raw
+ .trim()
+ .replace(/^[rbuf]{0,2}("""|''')/, "")
+ .replace(/("""|''')\s*$/, "")
+ .trim();
+}
+
+function stripDocPrefix(raw) {
+ return raw
+ .trim()
+ .replace(/^\/\*+/, "")
+ .replace(/\*+\/$/, "")
+ .replace(/^\*+/, "")
+ .trim();
+}
+
+// Replaces the contents of string and char literals with spaces, preserving
+// length so columns stay correct. Escapes are honoured so "\"" does not end it.
+function blankStrings(line, language) {
+ if (language === "py") return line;
+ let out = "";
+ let quote = null;
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i];
+ if (quote) {
+ if (ch === "\\") {
+ out += " ";
+ i++;
+ continue;
+ }
+ out += ch === quote ? ch : " ";
+ if (ch === quote) quote = null;
+ continue;
+ }
+ if (ch === '"') {
+ quote = ch;
+ out += ch;
+ continue;
+ }
+ if (ch === "'") {
+ const literal = CHAR_LITERAL.exec(line.slice(i));
+ if (!literal) {
+ // Prose, not a literal. Leave it alone.
+ out += ch;
+ continue;
+ }
+ out += `'${" ".repeat(literal[0].length - 2)}'`;
+ i += literal[0].length - 1;
+ continue;
+ }
+ if (ch === "/" && line[i + 1] === "/") return out + line.slice(i);
+ out += ch;
+ }
+ return out;
+}
+
+function lintTypeScript(files) {
+ if (!existsSync(join(REPO, OXLINT_BIN))) {
+ warn("frontend/node_modules/oxlint is missing, so TS/TSX was skipped. Run `task frontend:install`.");
+ return [];
+ }
+ return batch(files, ARGV_BUDGET).flatMap(runOxlint);
+}
+
+function batch(files, budget) {
+ const batches = [];
+ let current = [];
+ let size = 0;
+ for (const file of files) {
+ if (current.length > 0 && size + file.length + 1 > budget) {
+ batches.push(current);
+ current = [];
+ size = 0;
+ }
+ current.push(file);
+ size += file.length + 1;
+ }
+ if (current.length > 0) batches.push(current);
+ return batches;
+}
+
+function runOxlint(files) {
+ let stdout;
+ try {
+ // Invoked as `node ` rather than through npx: spawning a .cmd shim on
+ // Windows fails with EINVAL unless a shell is used, and a shell would mean
+ // quoting every path. It must also be the npm package rather than the
+ // standalone release binary, which accepts a jsPlugins config, skips loading
+ // it, and still reports success (oxc-project/oxc#25203).
+ stdout = execFileSync(process.execPath, [OXLINT_BIN, "--config", OXLINT_CONFIG, "--format=json", ...files], {
+ cwd: REPO,
+ encoding: "utf8",
+ maxBuffer: 1 << 28,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ } catch (error) {
+ // oxlint exits non-zero whenever it reports something, which is the normal case.
+ stdout = error.stdout ?? "";
+ if (!stdout.trim()) {
+ die(`oxlint failed on ${files.length} file(s): ${firstLine(error.stderr ?? error.message)}`);
+ }
+ }
+
+ return parseOxlint(stdout);
+}
+
+function firstLine(value) {
+ return value.toString().trim().split(/\r?\n/)[0];
+}
+
+function parseOxlint(stdout) {
+ const start = stdout.indexOf("{");
+ if (start < 0) die("oxlint produced no JSON report.");
+ let report;
+ try {
+ report = JSON.parse(stdout.slice(start));
+ } catch {
+ die("could not parse oxlint JSON output.");
+ }
+
+ // JS plugins are alpha, and their documented failure mode is being skipped
+ // silently while oxlint still reports success (oxc-project/oxc#25203).
+ // number_of_rules is the report saying whether the plugin's rule was actually
+ // registered. Without this check a dead plugin reads exactly like clean code.
+ if ((report.number_of_rules ?? 0) < 1) {
+ die("oxlint loaded no rules, so the comment plugin did not run. Refusing to report a pass.");
+ }
+
+ return (report.diagnostics ?? []).flatMap((d) => {
+ const parsed = /^(CMT\d{3})\s+(\S+):\s*(.*)$/.exec(d.message);
+ if (!parsed) return [];
+ const [, rule, , detail] = parsed;
+ const span = d.labels?.[0]?.span;
+ return [
+ {
+ file: d.filename.replace(/\\/g, "/"),
+ line: span?.line ?? 1,
+ column: span?.column ?? 1,
+ rule,
+ detail,
+ severity: RULES[rule].severity,
+ },
+ ];
+ });
+}
+
+function publish(findings, scope) {
+ if (flags.has("--json")) {
+ process.stdout.write(`${JSON.stringify({ mode: scope.mode, findings }, null, 2)}\n`);
+ return 0;
+ }
+
+ if (findings.length === 0) {
+ process.stdout.write(`comment-lint: clean (${scope.files.length} file${scope.files.length === 1 ? "" : "s"} in scope)\n`);
+ return 0;
+ }
+
+ const byFile = new Map();
+ for (const f of findings) {
+ if (!byFile.has(f.file)) byFile.set(f.file, []);
+ byFile.get(f.file).push(f);
+ }
+
+ for (const [file, group] of [...byFile.entries()].sort()) {
+ process.stdout.write(`\n${file}\n`);
+ for (const f of group.sort((a, b) => a.line - b.line)) {
+ process.stdout.write(` ${String(f.line).padStart(5)} ${ruleLabel(f.rule)} ${f.detail}\n`);
+ }
+ }
+
+ process.stdout.write(
+ `\ncomment-lint: ${findings.length} finding${findings.length === 1 ? "" : "s"} across ${byFile.size} file${byFile.size === 1 ? "" : "s"}\n`,
+ );
+
+ if (scope.mode === "all") {
+ process.stdout.write("Report-only mode: --all never fails, so the standing backlog can be worked through in chunks.\n");
+ return 0;
+ }
+
+ process.stdout.write(
+ "\nThe standard is devGuide/CODE_COMMENTS.md. A comment must carry information the\n" +
+ "code cannot; if a reader could derive it from the code in front of them, delete it.\n" +
+ "If a finding is genuinely wrong, put `comment-lint-allow: CMT00X` on the line above.\n",
+ );
+ return 1;
+}
+
+function warn(message) {
+ process.stderr.write(`comment-lint: ${message}\n`);
+}
+
+// A gate that cannot run must not report a pass. Reserved for the engine being
+// broken, as opposed to absent: a missing oxlint install is handled by skipping
+// with a warning, so the hook stays usable before `task frontend:install`.
+function die(message) {
+ process.stderr.write(`comment-lint: ${message}\n`);
+ process.exit(2);
+}
+
+// The fixture corpus is the contract between the two engines: the same rule set
+// applied to .java/.py by the line scanner and to .ts/.tsx by oxlint, with
+// fixtures/expected.json asserting what each file should produce.
+//
+// Expectations live outside the fixtures on purpose. An in-file marker would sit
+// inside the very comment under test, changing its word count and its run
+// length, so the fixture would stop being an example of the real thing.
+//
+// --selftest compare against expected.json
+// --selftest --update rewrite expected.json from current behaviour
+//
+// A rule change is meant to show up as a reviewable diff in expected.json.
+
+function runSelfTest() {
+ const dir = join(HERE, "fixtures");
+ const expectedPath = join(dir, "expected.json");
+ const files = readdirSync(dir)
+ .filter((f) => /\.(java|py|ts|tsx)$/.test(f))
+ .sort();
+ if (files.length === 0) {
+ warn("no fixtures found");
+ return 1;
+ }
+
+ const asRepoPath = (name) => relative(REPO, join(dir, name)).replace(/\\/g, "/");
+ const tsFixtures = files.filter((f) => TYPESCRIPT.test(f));
+ const tsFindings = tsFixtures.length > 0 ? lintTypeScript(tsFixtures.map(asRepoPath)) : [];
+
+ // A skipped engine looks exactly like a clean engine in the snapshot, so
+ // refuse to record or compare rather than baking in a false pass.
+ if (tsFixtures.length > 0 && tsFindings.length === 0) {
+ warn("the TS engine produced nothing, so it did not run. Install frontend deps first.");
+ return 1;
+ }
+
+ const actual = {};
+ for (const name of files) {
+ const found = TYPESCRIPT.test(name)
+ ? tsFindings.filter((f) => f.file.endsWith(`/${name}`))
+ : lintLineBased(asRepoPath(name));
+ actual[name] = found
+ .map((f) => `${f.line}:${f.rule}:${f.severity}`)
+ .sort((a, b) => Number(a.split(":")[0]) - Number(b.split(":")[0]));
+ }
+
+ if (flags.has("--update")) {
+ writeFileSync(expectedPath, `${JSON.stringify(actual, null, 2)}\n`);
+ process.stdout.write(
+ `comment-lint selftest: recorded ${Object.keys(actual).length} fixtures to ${relative(REPO, expectedPath)}\n`,
+ );
+ return 0;
+ }
+
+ if (!existsSync(expectedPath)) {
+ warn("fixtures/expected.json is missing. Run --selftest --update to record it.");
+ return 1;
+ }
+
+ // --quiet says nothing unless something is wrong. It is how the lint tasks run
+ // the corpus first without burying their own output under eleven ok lines.
+ const quiet = flags.has("--quiet");
+ const expected = JSON.parse(readFileSync(expectedPath, "utf8"));
+ let failures = 0;
+ for (const name of files) {
+ const want = (expected[name] ?? []).join(" | ");
+ const got = actual[name].join(" | ");
+ if (want === got) {
+ if (!quiet) process.stdout.write(`ok ${name} (${actual[name].length})\n`);
+ continue;
+ }
+ failures++;
+ process.stdout.write(`FAIL ${name}\n expected: ${want || "(nothing)"}\n actual: ${got || "(nothing)"}\n`);
+ }
+
+ const stale = Object.keys(expected).filter((n) => !files.includes(n));
+ for (const name of stale) {
+ failures++;
+ process.stdout.write(`FAIL ${name} is in expected.json but the fixture is gone\n`);
+ }
+
+ if (failures > 0) {
+ process.stdout.write(
+ `\ncomment-lint selftest: ${failures} fixture(s) differ. If intended, rerun with --update and review the diff.\n`,
+ );
+ return 1;
+ }
+ if (!quiet) process.stdout.write("\ncomment-lint selftest: both engines match the corpus\n");
+ return 0;
+}
+
+function isFlagValue(arg) {
+ const index = argv.indexOf(arg);
+ return index > 0 && argv[index - 1] === "--since";
+}
+
+function flagValue(flag) {
+ const index = argv.indexOf(flag);
+ return argv[index + 1] ?? "origin/main";
+}
+
+function git(args) {
+ // stderr is captured rather than inherited so git's line-ending advice ("CRLF
+ // will be replaced by LF") does not print once per file on Windows. Real
+ // failures still surface: execFileSync throws, and the caller reads .stderr.
+ return execFileSync("git", args, {
+ cwd: REPO,
+ encoding: "utf8",
+ maxBuffer: 1 << 28,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+}
diff --git a/scripts/lint/comment-rules.mjs b/scripts/lint/comment-rules.mjs
new file mode 100644
index 0000000000..4211be9689
--- /dev/null
+++ b/scripts/lint/comment-rules.mjs
@@ -0,0 +1,500 @@
+// The comment-quality rule set, shared by both engines so a rule means the same
+// thing everywhere: the oxlint JS plugin (which owns .ts/.tsx, and has real
+// comment tokens and an AST) and comment-lint.mjs (which owns .java and .py, and
+// has only lines). Neither engine ever scans the other's files, so the two can
+// differ in precision without producing contradictory findings on one file.
+//
+// The standard these rules enforce is devGuide/CODE_COMMENTS.md. Changing a rule
+// here without changing that document leaves the repo with two answers.
+//
+// Between them the engines read every comment form the repo writes: // and /* */,
+// Javadoc and JSDoc, JSX comments, # and Python docstrings.
+
+export const SEVERITY = { ERROR: "error", WARN: "warn" };
+
+// Every rule blocks. A rule that only warns is a rule nobody acts on, so a
+// finding that turns out to be wrong is a bug in the rule: narrow it, or mark the
+// line with comment-lint-allow and say why. Each rule below carries the readings
+// it deliberately excludes, which is where to start when one misfires.
+export const RULES = {
+ CMT001: { name: "restates-code", severity: SEVERITY.ERROR },
+ CMT002: { name: "banner", severity: SEVERITY.ERROR },
+ CMT003: { name: "step-narration", severity: SEVERITY.ERROR },
+ CMT004: { name: "diff-narration", severity: SEVERITY.ERROR },
+ CMT005: { name: "dead-code", severity: SEVERITY.ERROR },
+ CMT006: { name: "block-too-long", severity: SEVERITY.ERROR },
+ CMT007: { name: "doc-restates-signature", severity: SEVERITY.ERROR },
+ CMT008: { name: "bad-allow", severity: SEVERITY.ERROR },
+ CMT009: { name: "unowned-todo", severity: SEVERITY.ERROR },
+};
+
+export const MAX_BLOCK_LINES = 12;
+
+// CMT001 compares a comment against the code it introduces. Both sides are
+// reduced to the same shape first: lowercased, camel/snake/kebab split into
+// words, stop words and short words dropped. What survives is the information
+// each side actually carries, so "Handle drag start" and `handleDragStart` land
+// on the same set and the comment is shown to add nothing.
+
+const STOP_WORDS = new Set(
+ (
+ "a an the and or but if then else for to of in on at by with from into is are be was were this that these those it its as we our you your do" +
+ " does done use uses used using will would should can could may might not no yes new only also just so such via per each all any some more" +
+ " most other another same when while where which what who how why here there now next finally first second third let const var function" +
+ " return set get"
+ ).split(" "),
+);
+
+const WORD_RE = /[a-z][a-z0-9]*/g;
+
+export function contentWords(text) {
+ return (text.toLowerCase().match(WORD_RE) ?? []).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
+}
+
+export function identWords(text) {
+ const split = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_\-.]/g, " ");
+ return contentWords(split);
+}
+
+// Sentence punctuation marks prose, which is usually saying something the code
+// does not. A single trailing full stop does not count.
+const PROSE_PUNCT = /[.;:?!]/;
+const MAX_RESTATE_WORDS = 6;
+
+// Arrange/Act/Assert and Given/When/Then label the shape of a test rather than
+// describe the line beneath. Exempt only as a bare marker, so
+// `// Assert the cap is clamped to the tier maximum` is prose and judged on its
+// merits.
+const TEST_STRUCTURE = /^(arrange|act|assert|given|when|then)\b/i;
+const MAX_MARKER_WORDS = 4;
+
+export function restatesCode(body, codeText) {
+ if (TEST_STRUCTURE.test(body.trim()) && body.trim().split(/\s+/).length <= MAX_MARKER_WORDS) return false;
+ if (PROSE_PUNCT.test(body.replace(/\.$/, ""))) return false;
+ const comment = contentWords(body);
+ if (comment.length === 0 || comment.length > MAX_RESTATE_WORDS) return false;
+ const code = identWords(codeText);
+ if (code.length === 0) return false;
+
+ // Prefix matching either way, so "config" covers "configuration" and vice versa.
+ return comment.every((w) => code.some((k) => k.startsWith(w) || w.startsWith(k)));
+}
+
+const RULE_CHARS = /^[=~_*#+\-]{4,}|[─-╿]{4,}|[=~_*+]{4,}$/;
+const SECTION_LABEL = new RegExp(
+ "^(imports?|exports?|types?|interfaces?|constants?|config|helpers?|utils?|utilities|state|handlers?|callbacks?|effects?" +
+ "|render|rendering|styles?|props?|hooks?|setup|teardown|cleanup|main|public|private|internal|api|queries|mutations" +
+ "|selectors?|actions?|reducers?|components?|fields?|getters?|setters?|lifecycle|boilerplate)" +
+ "\\s*(section|area|block)?\\s*$",
+ "i",
+);
+
+export function isBanner(body) {
+ if (RULE_CHARS.test(body.trim())) return true;
+
+ // A label wrapped in decoration is still a label: strip the decoration first.
+ const bare = body
+ .replace(/[=~_*#+\-─-╿]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+ return bare.length > 0 && SECTION_LABEL.test(bare);
+}
+
+// A bare "1." is not narration: numbered lists are how a doc block enumerates
+// conditions or alternatives, and matching them buries the rule in false
+// positives. Only the explicit step form and sequencing adverbs qualify, and the
+// number needs a separator after it, so a wrapped line beginning "step 2 unmounts
+// + remounts the panel" reads as the prose it is.
+const STEP = /^(step\s*\d+(\.\d+)?\s*[:.)\-]|(then|next|finally|afterwards|lastly)\s*[,:]\s+\S)/i;
+
+export function isStepNarration(body) {
+ return STEP.test(body.trim());
+}
+
+// Only phrases that can be talking about the code's own past. Excluded because
+// each has an innocent reading that fires constantly:
+// "used to" alone - "Used to clamp the live line" means "is used to"
+// "previously" alone - "re-show even if previously dismissed" is runtime state
+// "was called" - collides with "verify getSession was called"
+// "left over from" - "no cards left over from the unfiltered grid"
+const DIFF_NARRATION = new RegExp(
+ "\\b((this|it|we|they|that) used to|used to (be|live|sit)" +
+ "|(this|it|that|which|the (class|method|field|code|file|module|palette|banner)) is no longer (needed|used)" +
+ "|renamed from|was (previously|formerly) (called|named|known)" +
+ "|instead of the old|has been (removed|replaced) )",
+ "i",
+);
+const REMOVAL_SUFFIX = /(^|\s)[-(—]\s*(removed|deleted|dropped|no longer needed)\s*\)?\s*$/i;
+
+export function isDiffNarration(body) {
+ const t = body.trim();
+ return DIFF_NARRATION.test(t) || REMOVAL_SUFFIX.test(t);
+}
+
+const CODE_KEYWORD = new RegExp(
+ "^(import|package|public|private|protected|static|final|abstract|class|interface|enum|record|extends|implements" +
+ "|def|async|await|const|let|var|function|export|return|if|else|elif|for|while|do|try|catch|finally|switch|case" +
+ "|throw|new|super|this|@[A-Za-z])\\b",
+);
+const STATEMENT_TAIL = /[;{}]\s*$/;
+const CALL_ONLY = /^[\w.$]+\s*\([^)]*\)\s*;?\s*$/;
+const ASSIGNMENT = /\S\s*=\s*\S/;
+
+export function looksLikeCode(line) {
+ const t = line.trim();
+ if (t.length === 0) return false;
+ if (CODE_KEYWORD.test(t)) return true;
+ if (CALL_ONLY.test(t)) return true;
+ return STATEMENT_TAIL.test(t) && ASSIGNMENT.test(t);
+}
+
+export const MIN_DEAD_CODE_RUN = 3;
+const DEAD_CODE_SHARE = 2 / 3;
+
+export function isDeadCodeRun(bodies) {
+ if (bodies.length < MIN_DEAD_CODE_RUN) return false;
+ const codeish = bodies.filter(looksLikeCode).length;
+ return codeish / bodies.length >= DEAD_CODE_SHARE;
+}
+
+// Every documented-parameter form this repo writes, so the rule is not quietly
+// Javadoc-only:
+// Javadoc / JSDoc @param blob The blob to download
+// Sphinx :param blob: The blob to download
+// Google docstring blob: The blob to download (under an Args: heading)
+// NumPy style is deliberately absent: it splits the name and the description
+// across two lines, and there is one instance of it in the tree.
+const PARAM_TAG = /^@param\s+(?:\{[^}]*\}\s+)?([\w$.]+)\s*-?\s*(.+)$/;
+const SPHINX_PARAM = /^:(?:param|arg|key)\s+(?:\S+\s+)?([\w.]+)\s*:\s*(.+)$/;
+const GOOGLE_PARAM = /^([a-z_][\w]*)\s*(?:\([^)]*\))?\s*:\s*(.+)$/;
+
+const RETURN_TAG = /^@returns?\s+(.+)$/;
+const SPHINX_RETURN = /^:returns?\s*:\s*(.+)$/;
+
+// Description adds nothing when every word in it already appears in the thing
+// being described. `@param blob - The blob to download` is the canonical case.
+//
+// No native linter covers this. eslint-plugin-jsdoc's require-param-description,
+// Checkstyle's NonEmptyAtclauseDescription and ruff's D-rules all check that a
+// description exists, not whether it says anything.
+export function docRestatesSignature(body, ownerName = "") {
+ const t = body.trim();
+
+ for (const pattern of [PARAM_TAG, SPHINX_PARAM, GOOGLE_PARAM]) {
+ const match = pattern.exec(t);
+ if (!match) continue;
+ const [, name, description] = match;
+ // Google form is just `name: description`, which also matches ordinary prose
+ // containing a colon. Require the description to be short and unpunctuated so
+ // "Note: the cap is clamped" is not read as a parameter called "note".
+ if (pattern === GOOGLE_PARAM && /[.;,]/.test(description)) return false;
+ return addsNothing(description, name, 5);
+ }
+
+ const returns = RETURN_TAG.exec(t) ?? SPHINX_RETURN.exec(t);
+ if (returns && ownerName) return addsNothing(returns[1], ownerName, 4);
+ return false;
+}
+
+function addsNothing(description, subject, limit) {
+ const words = contentWords(description);
+ if (words.length === 0 || words.length > limit) return false;
+ const known = identWords(subject);
+ return known.length > 0 && words.every((w) => known.some((k) => k.startsWith(w) || w.startsWith(k)));
+}
+
+// A TODO with no reference has nothing that will ever close it. An owner is not
+// accepted in its place: a username goes stale when someone leaves and means
+// nothing to an outside contributor, while an issue outlives both.
+//
+// Anchored at the start, so this catches a comment that *is* a TODO rather than
+// prose that mentions the word.
+const TODO_MARKER = /^(TODO|FIXME|HACK|XXX)\b/;
+
+// What counts as something that will close it: an issue, a link, or a security
+// advisory. Checked across the whole comment run, so the reference can sit on a
+// continuation line.
+const HAS_REFERENCE = /(#\d+|https?:\/\/|CVE-\d|GHSA-|[A-Z]{2,}-\d+)/;
+
+export function isUnownedTodo(body, runText = body) {
+ return TODO_MARKER.test(body) && !HAS_REFERENCE.test(runText);
+}
+
+// A rule id is silenced by `comment-lint-allow: CMT002`, on the comment itself or
+// on the line above it. There is deliberately no form that disables every rule.
+//
+// The whole comment must be the directive. Matching it anywhere in the text meant
+// prose that merely mentions the syntax silenced a rule, which this file's own
+// paragraph above did.
+const DIRECTIVE = /^comment-lint-allow:\s*(.+?)\s*$/i;
+
+export function isDirective(body) {
+ return DIRECTIVE.test(body.trim());
+}
+
+// A directive that names nothing real, or that suppresses nothing, is dead
+// configuration: it reads as a silenced rule while silencing nothing, and it
+// blinds the line for whoever inherits it. Reported for the same reason ESLint
+// has --report-unused-disable-directives and ruff has RUF100.
+class Allowance {
+ constructor(directives) {
+ this.entries = [];
+ for (const directive of directives) {
+ for (const token of directiveTokens(directive.body)) {
+ this.entries.push({ token, directive, known: token in RULES, used: false });
+ }
+ }
+ }
+
+ // Called only once a rule has decided it would report, so a directive counts
+ // as used when it actually silenced something. Asking before the rule decided
+ // marked every consulted directive as used, which hid the unused ones.
+ suppresses(rule) {
+ let allowed = false;
+ for (const entry of this.entries) {
+ if (entry.token !== rule) continue;
+ entry.used = true;
+ allowed = true;
+ }
+ return allowed;
+ }
+
+ reportUnused(report) {
+ for (const entry of this.entries) {
+ if (entry.used) continue;
+ const detail = entry.known ? `${entry.token} is allowed here but nothing reported it` : `${entry.token} is not a rule`;
+ report("CMT008", entry.directive.line, entry.directive.column, detail, entry.directive.body);
+ }
+ }
+}
+
+// Every token a directive names, valid or not, so an unknown one is reported
+// rather than quietly ignored. Matching only real ids would let `CMT999` through
+// as a silent no-op: it looks like a rule and silences nothing.
+export function directiveTokens(body) {
+ const match = DIRECTIVE.exec(body.trim());
+ if (!match) return [];
+ return match[1]
+ .split(",")
+ .map((token) => token.trim().toUpperCase())
+ .filter(Boolean);
+}
+
+// Generated files carry whatever the generator emits, and editing them to
+// satisfy a lint rule would be undone on the next regeneration.
+const GENERATED_MARKER = /AUTO-?GENERATED|@generated|DO NOT EDIT|Code generated by/i;
+const GENERATED_HEADER_LINES = 10;
+
+export function isGenerated(source) {
+ return GENERATED_MARKER.test(source.split("\n", GENERATED_HEADER_LINES).join("\n"));
+}
+
+export const EXCLUDED_PATHS = [
+ /(^|\/)node_modules\//,
+ /(^|\/)dist(-\w+)?\//,
+ /(^|\/)build\//,
+ /(^|\/)target\//,
+ /(^|\/)vendor\//,
+ /pdfjs/i,
+ /thirdParty/i,
+ /\.min\./,
+ /src-tauri\/gen\//,
+ /public\/locales\//,
+ /\.d\.ts$/,
+ /(^|\/)storybook-static\//,
+ /(^|\/)playwright-report\//,
+ /(^|\/)org\/apache\//,
+];
+
+export function isExcludedPath(file) {
+ const normalised = file.replace(/\\/g, "/");
+ return EXCLUDED_PATHS.some((re) => re.test(normalised));
+}
+
+// Comment text reduced to what a reader would call "the same comment": trimmed,
+// whitespace collapsed, comment markers and decoration stripped. Both sides of
+// the pre-existing check normalise through here so indentation and marker style
+// cannot make an unchanged comment look new.
+export function normaliseComment(text) {
+ return String(text)
+ .replace(/^[\s{]*(\/\/+|\/\*+|#+|\*+)/gm, " ")
+ .replace(/\*+\/[\s}]*$/gm, " ")
+ .replace(/\s+/g, " ")
+ .trim()
+ .toLowerCase();
+}
+
+// Every comment in a source file, normalised. Deliberately permissive and
+// language-agnostic: it only ever decides whether a finding is pre-existing, so
+// over-matching suppresses a duplicate comment and under-matching just reports
+// something the author can look at.
+export function commentBodiesOf(source) {
+ const bodies = new Set();
+ for (const raw of source.split(/\r?\n/)) {
+ const marker = /(\/\/+|\/\*+|^\s*\*+|#+)/.exec(raw);
+ if (!marker) continue;
+ const body = normaliseComment(raw.slice(marker.index));
+ if (body.length > 0) bodies.add(body);
+ }
+ return bodies;
+}
+
+export function ruleLabel(id) {
+ return `${id} ${RULES[id].name}`;
+}
+
+// Both engines funnel into this. They differ only in how they build `runs`: the
+// oxlint plugin reads real comment tokens, comment-lint.mjs scans lines. Keeping
+// the rule application here is what stops the two drifting apart.
+//
+// A "run" is a group of comment lines with no code between them, which is the
+// unit CMT005 and CMT006 judge. Shape:
+// { startLine, kind: "line" | "block" | "doc", lines: [{ line, column, body }] }
+// `line` is 1-based to match every editor and every diff.
+
+export function analyse({ lines, runs, isTestFile = false }) {
+ const findings = [];
+ // `body` is the comment's own text, kept alongside the formatted detail so the
+ // caller can ask whether this exact comment already existed before the change.
+ // That is what stops a reindent or a code move reporting comments nobody wrote.
+ const report = (rule, line, column, detail, body) => {
+ if (isTestFile && SUPPRESSED_IN_TESTS.has(rule)) return;
+ findings.push({ rule, line, column, detail, body: normaliseComment(body ?? detail), severity: RULES[rule].severity });
+ };
+
+ for (const run of runs) {
+ // A directive is scaffolding, not content. Leaving it in the run made it two
+ // lines long, and CMT001 only judges a one-line run, so any directive
+ // silenced CMT001 whatever rule it named.
+ const directives = run.lines.filter((l) => isDirective(l.body));
+ const content = run.lines.filter((l) => !isDirective(l.body));
+ const allowed = new Allowance(directives);
+
+ if (content.length === 0) {
+ allowed.reportUnused(report);
+ continue;
+ }
+
+ const bodies = content.map((l) => l.body);
+ const runText = bodies.join("\n");
+ const first = content[0];
+
+ // Only CMT004 and CMT009 judge a trailing comment. The others depend on the
+ // comment introducing the code below it, and a trailing comment sits beside
+ // it: `0x25 // "%PDF"` overlaps in words while adding the decoding, which is
+ // the kind of lower-altitude fact the standard asks for.
+ if (run.trailing) {
+ for (const entry of content) {
+ const body = entry.body.trim();
+ if (body.length === 0) continue;
+ if (isDiffNarration(body) && !allowed.suppresses("CMT004")) {
+ report("CMT004", entry.line, entry.column, truncate(body), body);
+ continue;
+ }
+ if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) {
+ report("CMT009", entry.line, entry.column, truncate(body), body);
+ }
+ }
+ allowed.reportUnused(report);
+ continue;
+ }
+
+ if (isDeadCodeRun(bodies) && !allowed.suppresses("CMT005")) {
+ report("CMT005", run.startLine, first.column, `${bodies.length} commented-out lines`, runText);
+ allowed.reportUnused(report);
+ continue; // Every other rule would pile onto the same block of dead code.
+ }
+
+ // A doc block is exempt: the standard asks for thorough contracts, so capping
+ // their length would argue with itself. This judges runs of implementation
+ // comment, where an essay means the code needs restructuring.
+ const essay = run.kind !== "doc" && content.length > MAX_BLOCK_LINES && run.startLine > FILE_HEADER_LINES;
+ if (essay && !allowed.suppresses("CMT006")) {
+ report("CMT006", run.startLine, first.column, `${content.length} lines, limit ${MAX_BLOCK_LINES}`, runText);
+ }
+
+ const owner = run.kind === "line" ? "" : nextCodeLine(lines, run);
+
+ for (const entry of content) {
+ const body = entry.body.trim();
+ if (body.length === 0) continue;
+
+ if (isBanner(body) && !allowed.suppresses("CMT002")) {
+ report("CMT002", entry.line, entry.column, truncate(body), body);
+ continue;
+ }
+ if (isStepNarration(body) && !allowed.suppresses("CMT003")) {
+ report("CMT003", entry.line, entry.column, truncate(body), body);
+ continue;
+ }
+ if (isDiffNarration(body) && !allowed.suppresses("CMT004")) {
+ report("CMT004", entry.line, entry.column, truncate(body), body);
+ continue;
+ }
+ if (docRestatesSignature(body, owner) && !allowed.suppresses("CMT007")) {
+ report("CMT007", entry.line, entry.column, truncate(body), body);
+ continue;
+ }
+ if (isUnownedTodo(body, runText) && !allowed.suppresses("CMT009")) {
+ report("CMT009", entry.line, entry.column, truncate(body), body);
+ continue;
+ }
+ }
+
+ // CMT001 judges a whole single-line run against the code it introduces, so
+ // a two-line comment that happens to echo one identifier is left alone.
+ // A one-line `/* … */` counts, which is how JSX `{/* Cap editor */}` above
+ // `` is caught. A doc block does not: it is a contract, and
+ // CMT007 is the rule that judges those.
+ if (run.kind !== "doc" && content.length === 1) {
+ const entry = first;
+ const body = entry.body.trim();
+ const code = nextCodeLine(lines, run);
+ if (code && !isBanner(body) && restatesCode(body, code) && !allowed.suppresses("CMT001")) {
+ report("CMT001", entry.line, entry.column, `${truncate(body)} -> ${truncate(code)}`, body);
+ }
+ }
+
+ allowed.reportUnused(report);
+ }
+
+ return findings.sort((a, b) => a.line - b.line || a.column - b.column);
+}
+
+// A file header is allowed to be as long as it needs to be.
+const FILE_HEADER_LINES = 5;
+const DETAIL_WIDTH = 58;
+
+// Both of these say something real in a test and nothing anywhere else. A
+// regression test explains itself by describing the old behaviour, and the e2e
+// specs number their comments to match a written manual test procedure.
+const SUPPRESSED_IN_TESTS = new Set(["CMT003", "CMT004"]);
+
+function precedingLine(lines, startLine) {
+ return lines[startLine - 2] ?? "";
+}
+
+function nextCodeLine(lines, run) {
+ const commentLines = new Set(run.lines.map((l) => l.line));
+ for (let i = run.startLine; i < lines.length; i++) {
+ const lineNumber = i + 1;
+ if (commentLines.has(lineNumber)) continue;
+ const text = lines[i]?.trim() ?? "";
+ if (text.length === 0) continue;
+ if (text.startsWith("//") || text.startsWith("#") || text.startsWith("*") || text.startsWith("/*")) continue;
+ if (text === "}" || text === "};" || text === ")" || text === ");") return "";
+ return text;
+ }
+ return "";
+}
+
+function truncate(text) {
+ const flat = text.replace(/\s+/g, " ").trim();
+ return flat.length > DETAIL_WIDTH ? `${flat.slice(0, DETAIL_WIDTH - 1)}…` : flat;
+}
+
+export const TEST_FILE = /\.(test|spec)\.[jt]sx?$|(^|\/)src\/test\/|Test\.java$|Tests\.java$|(^|\/)test_[^/]+\.py$|_test\.py$/;
+
+export function isTestPath(file) {
+ return TEST_FILE.test(file.replace(/\\/g, "/"));
+}
diff --git a/scripts/lint/fixtures/AaaTest.java b/scripts/lint/fixtures/AaaTest.java
new file mode 100644
index 0000000000..1aac398327
--- /dev/null
+++ b/scripts/lint/fixtures/AaaTest.java
@@ -0,0 +1,19 @@
+package fixtures;
+
+class AaaTest {
+
+ void clampsToTierMaximum() {
+ // Arrange
+ var wallet = walletAt(500);
+
+ // Act
+ var result = clamp(wallet);
+
+ // Assert
+ assertEquals(100, result.cap());
+
+ // Assert the cap is clamped rather than rejected, because the tier
+ // downgrade path relies on it.
+ assertTrue(result.clamped());
+ }
+}
diff --git a/scripts/lint/fixtures/README.md b/scripts/lint/fixtures/README.md
new file mode 100644
index 0000000000..ceb8f1044b
--- /dev/null
+++ b/scripts/lint/fixtures/README.md
@@ -0,0 +1,17 @@
+# comment-lint fixtures
+
+The contract between the two engines. Each file is a small, realistic example of
+what a rule fires on, or of something it must leave alone. `expected.json` records
+what every fixture should produce, down to the line and the severity.
+
+Expectations live outside the fixtures deliberately: an in-file `EXPECT:` marker
+would sit inside the comment under test, changing its word count and its run
+length, so the fixture would stop being an example of the real thing.
+
+```bash
+node scripts/lint/comment-lint.mjs --selftest # compare
+node scripts/lint/comment-lint.mjs --selftest --update # re-record, then review the diff
+```
+
+Adding a rule means adding a fixture that fires it and a line in a `clean.*`
+fixture that must not. A rule with no fixture is a rule nobody can safely change.
diff --git a/scripts/lint/fixtures/allow.java b/scripts/lint/fixtures/allow.java
new file mode 100644
index 0000000000..50fe34d21e
--- /dev/null
+++ b/scripts/lint/fixtures/allow.java
@@ -0,0 +1,32 @@
+package fixtures;
+
+class Allow {
+
+ void keptOnPurpose() {
+ // comment-lint-allow: CMT002
+ // ---------- kept on purpose, this fixture proves the escape hatch ----------
+ run();
+ }
+
+ void unknownToken(Session session) {
+ // comment-lint-allow: FAKE_RULE
+ session.close();
+ }
+
+ void looksLikeARuleButIsNot(Session session) {
+ // comment-lint-allow: CMT999
+ session.close();
+ }
+
+ void allowedButNothingFires(Session session) {
+ // comment-lint-allow: CMT001
+ // Closed once the signing round trip has settled, not before.
+ session.close();
+ }
+
+ void directiveMustNotHideOtherRules(Registry registry) {
+ // comment-lint-allow: CMT002
+ // Clear the registry
+ registry.clear();
+ }
+}
diff --git a/scripts/lint/fixtures/apostrophes.java b/scripts/lint/fixtures/apostrophes.java
new file mode 100644
index 0000000000..25d0745fd8
--- /dev/null
+++ b/scripts/lint/fixtures/apostrophes.java
@@ -0,0 +1,23 @@
+package fixtures;
+
+class Apostrophes {
+
+ /** The approver's team, which must match the one this server already belongs to. */
+ Long teamId;
+
+ // The caller's own retry budget applies here; this method does not retry.
+ void submit() {}
+
+ void literals() {
+ char quote = '\'';
+ char newline = '\n';
+ char slash = '/';
+ String path = "a//b";
+ }
+
+ /** Kept last: if the scanner desynchronises above, this stops being seen. */
+ void canary() {
+ // Build document
+ document = build();
+ }
+}
diff --git a/scripts/lint/fixtures/clean.java b/scripts/lint/fixtures/clean.java
new file mode 100644
index 0000000000..34f21c313c
--- /dev/null
+++ b/scripts/lint/fixtures/clean.java
@@ -0,0 +1,26 @@
+package fixtures;
+
+/**
+ * Authority on which filesystem locations a policy may read or write. Fail-closed:
+ * denied entirely under the saas profile, then Stirling's own config directory is
+ * always rejected, then the path must resolve inside an allowed root.
+ *
+ *
Compared after normalisation so {@code ..} cannot escape a root. Symlink
+ * escape is not defended: an operator who roots an allowlist on a symlink to a
+ * sensitive location is trusted.
+ */
+class Clean {
+
+ /** Returns the normalised absolute path; throws if not permitted. */
+ Path check(Path candidate) {
+ // whenComplete runs on the worker thread after the run finishes, so the
+ // terminal event never races the step events.
+ return candidate.toAbsolutePath().normalize();
+ }
+
+ void sizes() {
+ // Bytes, not KiB: the API contract predates the unit change and callers
+ // still send bytes.
+ long limit = 5_242_880L;
+ }
+}
diff --git a/scripts/lint/fixtures/clean.ts b/scripts/lint/fixtures/clean.ts
new file mode 100644
index 0000000000..cac6895417
--- /dev/null
+++ b/scripts/lint/fixtures/clean.ts
@@ -0,0 +1,17 @@
+/**
+ * Auth/session seam. saas keeps a Supabase web session; desktop keeps a JWT in
+ * the Tauri secure store, and cloud code reads the token through here instead.
+ * Default no-op; saas/ and desktop/ shadow it.
+ */
+export interface SessionSeam {
+ /** Bearer access token for authenticated API calls, or null when signed out. */
+ getAccessToken(): Promise;
+}
+
+export function createSeam(): SessionSeam {
+ return {
+ // Resolves null rather than throwing: a signed-out caller is an ordinary
+ // state here, and every consumer already branches on null.
+ getAccessToken: async () => null,
+ };
+}
diff --git a/scripts/lint/fixtures/deadcode.java b/scripts/lint/fixtures/deadcode.java
new file mode 100644
index 0000000000..e2ad5b887e
--- /dev/null
+++ b/scripts/lint/fixtures/deadcode.java
@@ -0,0 +1,14 @@
+package fixtures;
+
+class DeadCode {
+
+ // private void oldPath(PDDocument document) {
+ // PDPage page = document.getPage(0);
+ // page.setRotation(90);
+ // document.save(target);
+ // }
+
+ void currentPath(PDDocument document) {
+ document.save(target);
+ }
+}
diff --git a/scripts/lint/fixtures/docs.java b/scripts/lint/fixtures/docs.java
new file mode 100644
index 0000000000..f572dc545f
--- /dev/null
+++ b/scripts/lint/fixtures/docs.java
@@ -0,0 +1,12 @@
+package fixtures;
+
+class Docs {
+
+ /**
+ * @param blob the blob
+ * @param timeoutMs how long to wait before abandoning the read; the caller
+ * owns retrying, because only it knows whether the operation is
+ * idempotent
+ */
+ void download(Blob blob, long timeoutMs) {}
+}
diff --git a/scripts/lint/fixtures/docstrings.py b/scripts/lint/fixtures/docstrings.py
new file mode 100644
index 0000000000..b9c67023c7
--- /dev/null
+++ b/scripts/lint/fixtures/docstrings.py
@@ -0,0 +1,36 @@
+"""Module docstring, which the scanner must see as documentation."""
+
+
+def download(blob, timeout_ms):
+ """Fetch the blob.
+
+ Args:
+ blob: The blob
+ timeout_ms: How long to wait before abandoning the read; the caller owns
+ retrying, because only it knows whether the operation is idempotent.
+
+ Returns:
+ The fetched bytes.
+ """
+ return read(blob)
+
+
+def sphinx(blob):
+ """Fetch the blob.
+
+ :param blob: The blob
+ :returns: the sphinx result
+ """
+ return read(blob)
+
+
+def payload():
+ # Not documentation: a triple-quoted value, so its contents are data.
+ body = """{"key": "value", "note": "Types"}"""
+ return body
+
+
+def canary():
+ # Build document
+ document = build()
+ return document
diff --git a/scripts/lint/fixtures/expected.json b/scripts/lint/fixtures/expected.json
new file mode 100644
index 0000000000..34c89482a5
--- /dev/null
+++ b/scripts/lint/fixtures/expected.json
@@ -0,0 +1,63 @@
+{
+ "AaaTest.java": [],
+ "allow.java": [
+ "12:CMT008:error",
+ "17:CMT008:error",
+ "22:CMT008:error",
+ "28:CMT008:error",
+ "29:CMT001:error"
+ ],
+ "apostrophes.java": [
+ "20:CMT001:error"
+ ],
+ "clean.java": [],
+ "clean.ts": [],
+ "deadcode.java": [
+ "5:CMT005:error"
+ ],
+ "docs.java": [
+ "6:CMT007:error"
+ ],
+ "docstrings.py": [
+ "8:CMT007:error",
+ "21:CMT007:error",
+ "34:CMT001:error"
+ ],
+ "narration.java": [
+ "6:CMT003:error",
+ "9:CMT003:error"
+ ],
+ "narration.tsx": [
+ "2:CMT003:error",
+ "5:CMT004:error",
+ "13:CMT001:error"
+ ],
+ "restates.java": [
+ "6:CMT001:error",
+ "9:CMT001:error",
+ "17:CMT002:error",
+ "19:CMT002:error"
+ ],
+ "restates.py": [
+ "2:CMT001:error",
+ "5:CMT002:error"
+ ],
+ "restates.ts": [
+ "2:CMT001:error",
+ "14:CMT002:error",
+ "18:CMT002:error"
+ ],
+ "strings.java": [],
+ "templates.py": [
+ "22:CMT001:error"
+ ],
+ "todos.java": [
+ "6:CMT009:error"
+ ],
+ "trailing.java": [
+ "13:CMT009:error",
+ "14:CMT004:error",
+ "19:CMT009:error",
+ "20:CMT004:error"
+ ]
+}
diff --git a/scripts/lint/fixtures/narration.java b/scripts/lint/fixtures/narration.java
new file mode 100644
index 0000000000..6dcb69b981
--- /dev/null
+++ b/scripts/lint/fixtures/narration.java
@@ -0,0 +1,22 @@
+package fixtures;
+
+class Narration {
+
+ void export(Document document) {
+ // Step 1: collect the annotations
+ var annotations = document.annotations();
+
+ // Then, flatten them onto the page
+ document.flatten(annotations);
+
+ // IMPORTANT: do not reorder these
+ document.save();
+
+ // No longer needed after the storage migration
+ legacyCleanup();
+
+ // Ordering matters: flatten() reads the annotation list that save()
+ // clears, so a save first loses every annotation. See #6865.
+ document.close();
+ }
+}
diff --git a/scripts/lint/fixtures/narration.tsx b/scripts/lint/fixtures/narration.tsx
new file mode 100644
index 0000000000..fa931ce925
--- /dev/null
+++ b/scripts/lint/fixtures/narration.tsx
@@ -0,0 +1,17 @@
+export function Panel() {
+ // Step 1: read the cap
+ const cap = useCap();
+
+ // This used to be initialised by the footer, which mounted after the banner.
+ useConsentBanner();
+
+ // CRITICAL: keep this above the early return
+ useLayoutEffect(() => sync(cap), [cap]);
+
+ return (
+
+ {/* Cap editor */}
+
+
+ );
+}
diff --git a/scripts/lint/fixtures/restates.java b/scripts/lint/fixtures/restates.java
new file mode 100644
index 0000000000..47b2e004b8
--- /dev/null
+++ b/scripts/lint/fixtures/restates.java
@@ -0,0 +1,24 @@
+package fixtures;
+
+class Restates {
+
+ void run(Registry registry, Job job) {
+ // Clear the registry
+ registry.clear();
+
+ // Sanitize filename
+ String safeFilename = sanitizeFilename(job.originalFilename());
+
+ // Wait for the worker to drain before clearing, or an in-flight job
+ // re-registers its temp file after the sweep.
+ registry.awaitQuiescence();
+ }
+
+ // ---------- Internal helpers ----------
+
+ // Types
+ private enum Mode {
+ FAST,
+ SAFE
+ }
+}
diff --git a/scripts/lint/fixtures/restates.py b/scripts/lint/fixtures/restates.py
new file mode 100644
index 0000000000..b952a934c0
--- /dev/null
+++ b/scripts/lint/fixtures/restates.py
@@ -0,0 +1,9 @@
+def build(request):
+ # Get the current status
+ status = request.current_status()
+
+ # ---- helpers ----
+
+ # Truncated to 200 chars because the audit column is varchar(200) and a
+ # longer value fails the insert rather than being trimmed.
+ return status[:200]
diff --git a/scripts/lint/fixtures/restates.ts b/scripts/lint/fixtures/restates.ts
new file mode 100644
index 0000000000..3b19eb1f10
--- /dev/null
+++ b/scripts/lint/fixtures/restates.ts
@@ -0,0 +1,20 @@
+export function useGrid() {
+ // Handle drag start
+ const handleDragStart = (event: DragStartEvent) => event.active.id;
+
+ // Selection state
+ const selection = new Set();
+
+ // Debounced so a fast drag does not queue a layout pass per pointer move.
+ const updateLayout = debounce(() => measure(), 16);
+
+ return { handleDragStart, selection, updateLayout };
+}
+
+// ─── Types ────────────────────────────────────────────────────────────────
+
+export type Gate = "OFFSITE_PROCESSING" | "AUTOMATION";
+
+// Helpers
+
+export function noop() {}
diff --git a/scripts/lint/fixtures/strings.java b/scripts/lint/fixtures/strings.java
new file mode 100644
index 0000000000..d3600ceb3d
--- /dev/null
+++ b/scripts/lint/fixtures/strings.java
@@ -0,0 +1,11 @@
+package fixtures;
+
+class Strings {
+
+ // A `//` inside a literal is not a comment, and neither is an escaped quote.
+ void urls() {
+ String docs = "https://example.com/guide";
+ String quoted = "a \" then // not a comment";
+ char slash = '/';
+ }
+}
diff --git a/scripts/lint/fixtures/templates.py b/scripts/lint/fixtures/templates.py
new file mode 100644
index 0000000000..86e11edea8
--- /dev/null
+++ b/scripts/lint/fixtures/templates.py
@@ -0,0 +1,24 @@
+"""Templates that open mid-line, whose closing delimiter starts a line."""
+
+_TOOL_IO = '''
+TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {{
+{declarations}
+}}
+'''
+
+_HEADER = """
+# Header emitted into the output file.
+# Types
+"""
+
+
+def resolve(schema):
+ if "$ref" in schema:
+ return lookup(schema["$ref"])
+ return schema
+
+
+def canary():
+ # Build document
+ document = build()
+ return document
diff --git a/scripts/lint/fixtures/todos.java b/scripts/lint/fixtures/todos.java
new file mode 100644
index 0000000000..2287ca143e
--- /dev/null
+++ b/scripts/lint/fixtures/todos.java
@@ -0,0 +1,25 @@
+package fixtures;
+
+class Todos {
+
+ void bare() {
+ // TODO: re-enable once account syncing lands
+ skip();
+ }
+
+ void referenced() {
+ // TODO(#1234): re-enable once account syncing lands
+ skip();
+ }
+
+ void linked() {
+ // FIXME: the upstream fix is tracked at https://example.com/issues/9
+ workaround();
+ }
+
+ void mentioned() {
+ // Image placeholders are not scored: their body text is a TODO marker
+ // rather than prose, so scoring it would reward the placeholder.
+ score();
+ }
+}
diff --git a/scripts/lint/fixtures/trailing.java b/scripts/lint/fixtures/trailing.java
new file mode 100644
index 0000000000..58077e4561
--- /dev/null
+++ b/scripts/lint/fixtures/trailing.java
@@ -0,0 +1,22 @@
+package fixtures;
+
+class Trailing {
+
+ void decodings() {
+ byte[] header = {0x25, 0x50, 0x44, 0x46}; // "%PDF"
+ long maxSize = 50L * 1024 * 1024; // 50 MB
+ double buffer = 0.10; // 10% headroom
+ int mode = 2; // MB
+ }
+
+ void stillJudged() {
+ boolean supportsSign = false; // TODO make Sign work
+ cleanup(); // this used to run before the flush
+ }
+
+ void blockFormToo() {
+ int mode = 2; /* MB */
+ boolean ready = false; /* TODO wire this up */
+ reset(); /* this used to run before the flush */
+ }
+}
From 1c055f3d1857e19e4e70c76436e95585abe83563 Mon Sep 17 00:00:00 2001
From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:27:05 +0000
Subject: [PATCH 14/27] Centre modals in the viewport instead of pinning them
near the top (#7715)
## What
Every dialog in the processor is the shared `.sui-modal` shell, and its
backdrop was top-aligning the panel:
```css
align-items: flex-start;
padding: 5rem 1.5rem 1.5rem; /* 80px above, 24px below */
```
On a 900px-tall viewport that started every dialog at `y=80` with ~350px
of dead space beneath it. Phones already had an `align-items: center`
override; desktop never got one.
## Change
`frontend/editor/src/core/ui/Modal.css` only:
- Symmetric block inset, `align-items: center`.
- The inset is published as `--modal-inset-block`, and `.sui-modal`'s
`max-height` derives from it. That coupling is the point: if the two
drift apart, a tall modal overflows a centre-aligned backdrop and loses
its header off the top of the screen, unreachable.
- The phone breakpoint now only moves the variable. Measured at 375x812
it resolves to exactly the previous values (`16px 12px`, `max-height:
780px`), so mobile behaviour is unchanged.
One shared file, so this covers flow modals, source / user / pipeline /
API-key modals, billing and procurement.
## Before / After
## Testing
- `task frontend:check` passes (lint + typecheck + 2356 tests).
- Phone breakpoint measured directly in the browser, values match the
previous behaviour.
---
frontend/editor/src/core/ui/Modal.css | 24 ++++++++++++++----------
1 file changed, 14 insertions(+), 10 deletions(-)
diff --git a/frontend/editor/src/core/ui/Modal.css b/frontend/editor/src/core/ui/Modal.css
index 2988ec920e..e41ffb0eda 100644
--- a/frontend/editor/src/core/ui/Modal.css
+++ b/frontend/editor/src/core/ui/Modal.css
@@ -1,11 +1,16 @@
+/* The inset is symmetric so the panel sits in the optical centre of the viewport rather than
+ riding the top edge. It is published as a var because .sui-modal's max-height has to be the
+ viewport minus both halves of it — if the two drift apart a tall modal overflows the backdrop
+ and, because the panel is centre-aligned, loses its header off the top of the screen. */
.sui-modal__backdrop {
+ --modal-inset-block: 2.5rem;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
- align-items: flex-start;
+ align-items: center;
justify-content: center;
- padding: 5rem 1.5rem 1.5rem;
+ padding: var(--modal-inset-block) 1.5rem;
z-index: 100;
animation: fadeIn 0.18s ease both;
overscroll-behavior: contain;
@@ -24,20 +29,19 @@
display: flex;
flex-direction: column;
width: 100%;
- max-height: calc(100vh - 6.5rem);
- max-height: calc(100dvh - 6.5rem); /* mobile browser chrome shrinks 100vh */
+ max-height: calc(100vh - var(--modal-inset-block) * 2);
+ /* mobile browser chrome shrinks 100vh */
+ max-height: calc(100dvh - var(--modal-inset-block) * 2);
overflow: hidden;
animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1) both;
}
-/* Phones: drop the tall top inset so the modal gets the vertical space */
+/* Phones: tighten the inset so the modal gets the vertical space. Only the variable moves —
+ the max-height above follows it, so the pair cannot fall out of step. */
@media (max-width: 30rem) {
.sui-modal__backdrop {
- padding: 1rem 0.75rem;
- align-items: center;
- }
- .sui-modal {
- max-height: calc(100dvh - 2rem);
+ --modal-inset-block: 1rem;
+ padding-inline: 0.75rem;
}
}
From d3708c1e63f5161d1143b780f497c6f4b671f58a Mon Sep 17 00:00:00 2001
From: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Date: Fri, 28 Aug 2026 13:02:47 +0000
Subject: [PATCH 15/27] Highlight the rail entry whose tool is open (#7723)
---
.../shared/quickNav/QuickNavHostBridge.tsx | 3 ++
.../shared/quickNav/QuickNavRailHost.tsx | 4 +++
.../contexts/QuickNavHostContext.test.tsx | 31 +++++++++++++++++++
.../src/core/contexts/QuickNavHostContext.tsx | 7 +++++
frontend/editor/src/core/pages/HomePage.tsx | 1 +
5 files changed, 46 insertions(+)
diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx
index 9a9101a826..d97674464e 100644
--- a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx
+++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx
@@ -22,6 +22,7 @@ export interface QuickNavHostBridgeProps {
requestNavigation?: (go: () => void) => void;
onGoToDefaultState?: () => void;
onSelectTool?: (toolId: ToolId) => void;
+ activeTool?: ToolId | null;
/** Merged over the reasons worked out here, for what only the app can see. */
toolReasons?: QuickNavToolReasons;
}
@@ -34,6 +35,7 @@ export function QuickNavHostBridge({
onOpenSettings,
requestNavigation,
onSelectTool,
+ activeTool = null,
onGoToDefaultState,
toolReasons,
}: QuickNavHostBridgeProps) {
@@ -59,6 +61,7 @@ export function QuickNavHostBridge({
signingBadge,
portalAccess,
readerMode,
+ activeTool,
notificationsOpen,
toolReasons: mergedToolReasons,
},
diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx
index 3fba4d7e1f..e7a1b6e8c6 100644
--- a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx
+++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx
@@ -48,6 +48,8 @@ export function QuickNavRailHost() {
else go(route);
};
+ const openingTool = (id: ToolId) => ({ current: host?.activeTool === id });
+
const unusable = (id: ToolId) => {
const reason = host?.toolReasons?.[id];
return { disabled: Boolean(reason), reason };
@@ -135,6 +137,7 @@ export function QuickNavRailHost() {
icon: (
),
+ ...openingTool("automate"),
...unusable("automate"),
onClick: () => openTool("automate", "/automate"),
},
@@ -146,6 +149,7 @@ export function QuickNavRailHost() {
),
badge: host?.signingBadge,
badgeTone: "warning",
+ ...openingTool("sharedSign"),
...unusable("sharedSign"),
onClick: () => openTool("sharedSign", "/shared-sign"),
},
diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx
index fc648cbfe5..0a348262fa 100644
--- a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx
+++ b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx
@@ -13,6 +13,7 @@ function Probe({ onRead }: { onRead: (value: unknown) => void }) {
appMounted: host?.appMounted,
chromeless: host?.chromeless,
identity: host?.identity,
+ activeTool: host?.activeTool,
openSettings: Boolean(host?.actions.current?.openSettings),
});
return null;
@@ -26,6 +27,11 @@ function App() {
return null;
}
+function AppWithTool({ tool }: { tool: "automate" | null }) {
+ useRegisterQuickNavHost({ activeTool: tool }, {});
+ return null;
+}
+
function LoginRoute() {
useSuppressQuickNavRail();
return null;
@@ -71,6 +77,31 @@ describe("QuickNavHostContext", () => {
expect(after.openSettings).toBe(false);
});
+ it("clears the open tool when the next app registers without one", () => {
+ let latest: Record = {};
+ const view = render(
+
+ (latest = value as Record)}
+ />
+
+ ,
+ );
+ expect(latest.activeTool).toBe("automate");
+
+ act(() => {
+ view.rerender(
+
+ (latest = value as Record)}
+ />
+
+ ,
+ );
+ });
+ expect(latest.activeTool).toBe(null);
+ });
+
it("hides the bar while a route with no app chrome is on screen", () => {
// appMounted is sticky, so it can't answer "is an app on screen now".
const { view, read } = setup();
diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx
index 540ec8cd6c..1a19cffc6f 100644
--- a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx
+++ b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx
@@ -24,6 +24,7 @@ export interface QuickNavHostData {
signingBadge: number;
portalAccess: boolean;
readerMode: boolean;
+ activeTool: ToolId | null;
/** The app owns the panel; the rail's bell only reports its state. */
notificationsOpen: boolean;
/** Translated; absent means usable. */
@@ -61,6 +62,7 @@ const EMPTY_DATA: QuickNavHostData = {
signingBadge: 0,
portalAccess: false,
readerMode: false,
+ activeTool: null,
notificationsOpen: false,
hasSettings: false,
};
@@ -90,6 +92,7 @@ export function QuickNavHostProvider({ children }: { children: ReactNode }) {
merged.signingBadge === prev.signingBadge &&
merged.portalAccess === prev.portalAccess &&
merged.readerMode === prev.readerMode &&
+ merged.activeTool === prev.activeTool &&
merged.notificationsOpen === prev.notificationsOpen &&
merged.hasSettings === prev.hasSettings &&
merged.identity?.displayName === prev.identity?.displayName &&
@@ -143,6 +146,7 @@ export function useRegisterQuickNavHost(
signingBadge,
portalAccess,
readerMode,
+ activeTool,
notificationsOpen,
toolReasons,
} = data;
@@ -155,6 +159,8 @@ export function useRegisterQuickNavHost(
signingBadge: signingBadge ?? 0,
portalAccess: portalAccess ?? false,
readerMode: readerMode ?? false,
+ // Cleared, not omitted as toolReasons is: a stale tool marks an entry.
+ activeTool: activeTool ?? null,
notificationsOpen: notificationsOpen ?? false,
// Omitted when unknown, so the last answer survives a re-fetch.
...(toolReasons ? { toolReasons } : {}),
@@ -168,6 +174,7 @@ export function useRegisterQuickNavHost(
signingBadge,
portalAccess,
readerMode,
+ activeTool,
notificationsOpen,
toolReasons,
hasSettings,
diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx
index efb6af40d2..6434acbc9e 100644
--- a/frontend/editor/src/core/pages/HomePage.tsx
+++ b/frontend/editor/src/core/pages/HomePage.tsx
@@ -525,6 +525,7 @@ export default function HomePage() {
onSetReaderMode={setReaderMode}
onGoToDefaultState={goToDefaultState}
onSelectTool={handleToolSelect}
+ activeTool={selectedToolKey}
toolReasons={quickNavToolReasons}
/>
From c22d9ecf5801a75c84da927788a5bce2092c56d9 Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Sat, 29 Aug 2026 00:22:05 +0000
Subject: [PATCH 16/27] feat(editor): move the admin directory onto TanStack
Query (#7726)
# Description of Changes
Step 5 of the TanStack Query rollout, covering the admin People, Teams
and Team details screens. Follows #7264, #7283, #7285.
## The problem
Two separate ones, in the same three files.
**Reads.** Each section fetched and held its own copy of the same
resources: People read the roster and the team list, Teams read the team
list plus the roster again when its add-member modal opened, Team
details read all three. Cost scaled with how many screens you visited
rather than with how much data exists.
**Writes.** Thirteen handlers each did the same five things by hand: set
a processing flag, call the service, toast the outcome, dig a message
out of an axios error, and reload their own slice. Refreshing was a
convention, not a mechanism, and one handler had already forgotten it.
## The fix
Three shared query keys (`adminUsers`, `teams`, `teamDetails`), and one
`useAdminMutation` helper that every write is declared against:
```ts
const createTeam = useAdminMutation({
write: (name: string) => teamService.createTeam(name),
invalidates: ["teams"],
success: t("workspace.teams.createTeam.success"),
errorFallback: t("workspace.teams.createTeam.error"),
onDone: () => { setNewTeamName(""); setCreateModalOpened(false); },
});
```
Each write names the slices it disturbs, which is the part that only
works when reads and writes are designed together: `createTeam`
invalidates the team list, while a membership move invalidates the list,
both teams' detail rows and the roster, because it genuinely changes all
three. Invalidation refetches only mounted queries, so this costs
nothing extra.
The blanket "invalidate everything" helper survives in exactly one role:
child components (invite, password change, seat update) that write
through their own services, where the affected scopes are not visible
from the call site.
## Why it is better, measured
Request counts come from one harness driving `teams -> team details ->
back -> people`, run against the branch point and against this branch.
The assertion is committed, so it cannot silently regress.
| | Before | After |
|---|---|---|
| Requests | 7 | **3** |
| `getTeams` | 4 | **1** |
| `getUsers` | 2 | **1** |
| `getTeamDetails` | 1 | 1 |
| Committed renders | 17 | **15** |
Three is one per distinct resource, the floor for that sequence. The
four `getTeams` were the Teams table, Team details fetching the same
list for its "move to team" dropdown, the explicit refresh on the back
button, and People.
Renders barely move, which is expected: this changes where data lives,
not how often React draws. It is reported because a caching change can
quietly cost renders, and this one does not.
On the code itself, across the three sections:
| | |
|---|---|
| Net lines | **-216** |
| `useState`/`useEffect` removed | **11**, none added |
| Duplicated `isAxiosError` blocks | 13 to **1** |
| `setProcessing` calls | 19 to **0** |
`isAxiosError` is no longer imported by any of the three files.
## Bug fixed
`disableMfaByAdmin` showed a success toast and never refreshed. The menu
item renders only when `user.mfaEnabled` is true, so an admin disabled
MFA, was told it worked, and watched the option stay on screen until a
manual reload. It is covered by a test that fails if the invalidation is
removed.
## Behaviour worth checking in review
- A write no longer blocks its handler before closing the modal. The
dialog closes when the write succeeds and the table updates when the
refetch lands, rather than the button spinning through both.
- Modal submit buttons now track their own mutation rather than one
shared flag. Team details still derives a single busy flag, now from its
five mutations rather than a `useState`, so its row actions disable
together as before.
- The per-handler `console.error` is kept, once, in the shared error
path.
## Testing
Five tests, each verified by breaking the implementation and confirming
that one test, and only that one, fails:
| Mutation | Caught by |
|---|---|
| Drop the shared stale window (`staleTime: 0`) | request-count test |
| Make invalidation a no-op | write-visibility test |
| Ignore the login-enabled gate | login-disabled test |
| Stop invalidating after the MFA write | MFA-refresh test |
| Fall back to the generic error message | server-message test |
The write tests drive the real flows through their modals and menus
rather than calling hooks directly.
`task frontend:check` passes typecheck, lint and oxfmt, and 2383 of 2385
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, are untouched here and fail identically
with this branch's changes reverted.
## Scope
The three services keep their current shape; nothing outside these three
sections and the new hook module changes. Child modals that write
through their own services still refresh via the blanket helper, and
converting those is separate work.
---
frontend/editor/src/core/query/keys.ts | 4 +
.../config/configSections/PeopleSection.tsx | 477 +++++++-----------
.../configSections/TeamDetailsSection.tsx | 393 ++++++---------
.../config/configSections/TeamsSection.tsx | 216 +++-----
.../config/configSections/adminReads.test.tsx | 295 +++++++++++
.../proprietary/hooks/useAdminDirectory.ts | 151 ++++++
6 files changed, 885 insertions(+), 651 deletions(-)
create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/adminReads.test.tsx
create mode 100644 frontend/editor/src/proprietary/hooks/useAdminDirectory.ts
diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts
index 5354b56b63..d95f674011 100644
--- a/frontend/editor/src/core/query/keys.ts
+++ b/frontend/editor/src/core/query/keys.ts
@@ -1,5 +1,7 @@
/** Editor query keys: ["editor", , ...params]. */
export const qk = {
+ /** The admin directory payload: a different endpoint and shape to qk.users(). */
+ adminUsers: () => ["editor", "adminUsers"] as const,
appConfig: () => ["editor", "appConfig"] as const,
endpointsAvailability: () => ["editor", "endpointsAvailability"] as const,
endpointEnabled: (endpoint: string) =>
@@ -9,5 +11,7 @@ export const qk = {
/** Keyed on the asking identity: two users must never share one answer. */
portalAccess: (userId: string | null) =>
["editor", "portalAccess", userId] as const,
+ teamDetails: (teamId: number) => ["editor", "teamDetails", teamId] as const,
+ teams: () => ["editor", "teams"] as const,
users: () => ["editor", "users"] as const,
} as const;
diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx
index d955cb341b..6b14d2b421 100644
--- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx
@@ -1,5 +1,4 @@
-import { useState, useEffect } from "react";
-import { isAxiosError } from "axios";
+import { useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import {
Stack,
@@ -20,14 +19,12 @@ import {
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import LocalIcon from "@app/components/shared/LocalIcon";
-import { alert } from "@app/components/toast";
import {
userManagementService,
User,
} from "@app/services/userManagementService";
-import { teamService, Team } from "@app/services/teamService";
+import { type Team } from "@app/services/teamService";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
-import { useAppConfig } from "@app/contexts/AppConfigContext";
import InviteMembersModal from "@app/components/shared/InviteMembersModal";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
@@ -36,17 +33,109 @@ import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton";
import { useLicense } from "@app/contexts/LicenseContext";
import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal";
import { useAuth } from "@app/auth/UseSession";
+import {
+ useAdminUsers,
+ useTeams,
+ useAdminMutation,
+ useInvalidateAdminDirectory,
+} from "@app/hooks/useAdminDirectory";
+
+const EXAMPLE_USERS: User[] = [
+ {
+ id: 1,
+ username: "admin",
+ email: "admin@example.com",
+ enabled: true,
+ roleName: "ROLE_ADMIN",
+ rolesAsString: "ROLE_ADMIN",
+ authenticationType: "password",
+ isActive: true,
+ lastRequest: Date.now(),
+ team: { id: 1, name: "Engineering" },
+ },
+ {
+ id: 2,
+ username: "john.doe",
+ email: "john.doe@example.com",
+ enabled: true,
+ roleName: "ROLE_USER",
+ rolesAsString: "ROLE_USER",
+ authenticationType: "password",
+ isActive: false,
+ lastRequest: Date.now() - 86400000,
+ team: { id: 1, name: "Engineering" },
+ },
+ {
+ id: 3,
+ username: "jane.smith",
+ email: "jane.smith@example.com",
+ enabled: true,
+ roleName: "ROLE_USER",
+ rolesAsString: "ROLE_USER",
+ authenticationType: "oauth",
+ isActive: true,
+ lastRequest: Date.now(),
+ team: { id: 2, name: "Marketing" },
+ },
+ {
+ id: 4,
+ username: "bob.wilson",
+ email: "bob.wilson@example.com",
+ enabled: false,
+ roleName: "ROLE_USER",
+ rolesAsString: "ROLE_USER",
+ authenticationType: "password",
+ isActive: false,
+ lastRequest: Date.now() - 604800000,
+ team: undefined,
+ },
+];
+
+const EXAMPLE_TEAMS: Team[] = [
+ { id: 1, name: "Engineering", userCount: 3 },
+ { id: 2, name: "Marketing", userCount: 2 },
+];
+
+const EXAMPLE_LICENSE = {
+ maxAllowedUsers: 10,
+ availableSlots: 6,
+ grandfatheredUserCount: 0,
+ licenseMaxUsers: 5,
+ premiumEnabled: true,
+ totalUsers: 4,
+};
export default function PeopleSection() {
const { t } = useTranslation();
- const { config } = useAppConfig();
const { loginEnabled } = useLoginRequired();
const { user: currentUser } = useAuth();
const navigate = useNavigate();
const { licenseInfo: globalLicenseInfo } = useLicense();
- const [users, setUsers] = useState([]);
- const [teams, setTeams] = useState([]);
- const [loading, setLoading] = useState(true);
+ const admin = useAdminUsers(loginEnabled);
+ const { data: fetchedTeams } = useTeams(loginEnabled);
+ const refreshDirectory = useInvalidateAdminDirectory();
+
+ // Session and MFA state arrive alongside the roster, keyed by username.
+ const fetchedUsers = useMemo(() => {
+ if (!admin.data) return [];
+ return admin.data.users.map((user) => ({
+ ...user,
+ isActive: admin.data.userSessions[user.username] || false,
+ lastRequest: admin.data.userLastRequest[user.username] || undefined,
+ mfaEnabled:
+ (
+ admin.data.userSettings?.[user.username] as
+ | Record
+ | undefined
+ )?.mfaEnabled === "true",
+ }));
+ }, [admin.data]);
+
+ // Login off means the endpoints are not callable, so the table shows a
+ // worked example instead of an empty state.
+ const users = loginEnabled ? fetchedUsers : EXAMPLE_USERS;
+ const teams = loginEnabled ? (fetchedTeams ?? []) : EXAMPLE_TEAMS;
+ const loading = loginEnabled && admin.isPending;
const [searchQuery, setSearchQuery] = useState("");
const [inviteModalOpened, setInviteModalOpened] = useState(false);
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
@@ -54,19 +143,20 @@ export default function PeopleSection() {
useState(false);
const [passwordUser, setPasswordUser] = useState(null);
const [selectedUser, setSelectedUser] = useState(null);
- const [processing, setProcessing] = useState(false);
- const [mailEnabled, setMailEnabled] = useState(false);
- const [lockedUsers, setLockedUsers] = useState([]);
-
- // License information
- const [licenseInfo, setLicenseInfo] = useState<{
- maxAllowedUsers: number;
- availableSlots: number;
- grandfatheredUserCount: number;
- licenseMaxUsers: number;
- premiumEnabled: boolean;
- totalUsers: number;
- } | null>(null);
+ const mailEnabled = loginEnabled ? (admin.data?.mailEnabled ?? false) : false;
+ const lockedUsers = loginEnabled ? (admin.data?.lockedUsers ?? []) : [];
+ const licenseInfo = loginEnabled
+ ? admin.data
+ ? {
+ maxAllowedUsers: admin.data.maxAllowedUsers,
+ availableSlots: admin.data.availableSlots,
+ grandfatheredUserCount: admin.data.grandfatheredUserCount,
+ licenseMaxUsers: admin.data.licenseMaxUsers,
+ premiumEnabled: admin.data.premiumEnabled,
+ totalUsers: admin.data.totalUsers,
+ }
+ : null
+ : EXAMPLE_LICENSE;
const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false;
const handleAddMembersClick = () => {
if (!loginEnabled) {
@@ -115,253 +205,103 @@ export default function PeopleSection() {
teamId: undefined as number | undefined,
});
- useEffect(() => {
- fetchData();
- }, []);
+ const updateUserRole = useAdminMutation({
+ write: (payload: { username: string; role: string; teamId?: number }) =>
+ userManagementService.updateUserRole(payload),
+ // A role edit can also move the user, which changes both teams' counts.
+ invalidates: ["users", "teams"],
+ success: t("workspace.people.editMember.success"),
+ errorFallback: t("workspace.people.editMember.error"),
+ onDone: () => closeEditModal(),
+ });
- useEffect(() => {
- if (config) {
- console.log(
- "[PeopleSection] Email invites enabled:",
- config.enableEmailInvites,
- );
- }
- }, [config]);
+ const toggleEnabled = useAdminMutation({
+ write: (user: User) =>
+ userManagementService.toggleUserEnabled(user.username, !user.enabled),
+ invalidates: ["users"],
+ success: t("workspace.people.toggleEnabled.success"),
+ errorFallback: t("workspace.people.toggleEnabled.error"),
+ });
- const fetchData = async () => {
- try {
- setLoading(true);
+ const deleteUser = useAdminMutation({
+ write: (username: string) => userManagementService.deleteUser(username),
+ invalidates: ["users", "teams"],
+ success: t(
+ "workspace.people.deleteUserSuccess",
+ "User deleted successfully",
+ ),
+ errorFallback: t(
+ "workspace.people.deleteUserError",
+ "Failed to delete user",
+ ),
+ });
- if (loginEnabled) {
- const [adminData, teamsData] = await Promise.all([
- userManagementService.getUsers(),
- teamService.getTeams(),
- ]);
+ const unlockUser = useAdminMutation({
+ write: (username: string) => userManagementService.unlockUser(username),
+ invalidates: ["users"],
+ success: t(
+ "workspace.people.unlockUserSuccess",
+ "User account unlocked successfully",
+ ),
+ errorFallback: t(
+ "workspace.people.unlockUserError",
+ "Failed to unlock user account",
+ ),
+ });
- // Enrich users with session data
- const enrichedUsers = adminData.users.map((user) => ({
- ...user,
- isActive: adminData.userSessions[user.username] || false,
- lastRequest: adminData.userLastRequest[user.username] || undefined,
- mfaEnabled:
- (
- adminData.userSettings?.[user.username] as
- | Record
- | undefined
- )?.mfaEnabled === "true",
- }));
+ const disableMfa = useAdminMutation({
+ write: (username: string) =>
+ userManagementService.disableMfaByAdmin(username),
+ invalidates: ["users"],
+ success: t(
+ "workspace.people.mfa.adminDisableSuccess",
+ "MFA disabled successfully for user",
+ ),
+ errorFallback: t(
+ "workspace.people.mfa.adminDisableError",
+ "Failed to disable MFA for user",
+ ),
+ });
- setUsers(enrichedUsers);
- setTeams(teamsData);
-
- // Store license information
- setLicenseInfo({
- maxAllowedUsers: adminData.maxAllowedUsers,
- availableSlots: adminData.availableSlots,
- grandfatheredUserCount: adminData.grandfatheredUserCount,
- licenseMaxUsers: adminData.licenseMaxUsers,
- premiumEnabled: adminData.premiumEnabled,
- totalUsers: adminData.totalUsers,
- });
- setMailEnabled(adminData.mailEnabled);
- setLockedUsers(adminData.lockedUsers || []);
- } else {
- // Provide example data when login is disabled
- const exampleUsers: User[] = [
- {
- id: 1,
- username: "admin",
- email: "admin@example.com",
- enabled: true,
- roleName: "ROLE_ADMIN",
- rolesAsString: "ROLE_ADMIN",
- authenticationType: "password",
- isActive: true,
- lastRequest: Date.now(),
- team: { id: 1, name: "Engineering" },
- },
- {
- id: 2,
- username: "john.doe",
- email: "john.doe@example.com",
- enabled: true,
- roleName: "ROLE_USER",
- rolesAsString: "ROLE_USER",
- authenticationType: "password",
- isActive: false,
- lastRequest: Date.now() - 86400000,
- team: { id: 1, name: "Engineering" },
- },
- {
- id: 3,
- username: "jane.smith",
- email: "jane.smith@example.com",
- enabled: true,
- roleName: "ROLE_USER",
- rolesAsString: "ROLE_USER",
- authenticationType: "oauth",
- isActive: true,
- lastRequest: Date.now(),
- team: { id: 2, name: "Marketing" },
- },
- {
- id: 4,
- username: "bob.wilson",
- email: "bob.wilson@example.com",
- enabled: false,
- roleName: "ROLE_USER",
- rolesAsString: "ROLE_USER",
- authenticationType: "password",
- isActive: false,
- lastRequest: Date.now() - 604800000,
- team: undefined,
- },
- ];
-
- const exampleTeams: Team[] = [
- { id: 1, name: "Engineering", userCount: 3 },
- { id: 2, name: "Marketing", userCount: 2 },
- ];
-
- setUsers(exampleUsers);
- setTeams(exampleTeams);
- setMailEnabled(false);
- setLockedUsers([]);
-
- // Example license information
- setLicenseInfo({
- maxAllowedUsers: 10,
- availableSlots: 6,
- grandfatheredUserCount: 0,
- licenseMaxUsers: 5,
- premiumEnabled: true,
- totalUsers: 4,
- });
- }
- } catch (error) {
- console.error("[PeopleSection] Failed to fetch people data:", error);
- alert({ alertType: "error", title: "Failed to load people data" });
- } finally {
- setLoading(false);
- }
- };
-
- const handleUpdateUserRole = async () => {
+ const handleUpdateUserRole = () => {
if (!selectedUser) return;
-
- try {
- setProcessing(true);
- await userManagementService.updateUserRole({
- username: selectedUser.username,
- role: editForm.role,
- teamId: editForm.teamId,
- });
- alert({
- alertType: "success",
- title: t("workspace.people.editMember.success"),
- });
- closeEditModal();
- fetchData();
- } catch (error: unknown) {
- console.error("[PeopleSection] Failed to update user:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t("workspace.people.editMember.error");
- alert({ alertType: "error", title: errorMessage });
- } finally {
- setProcessing(false);
- }
+ updateUserRole.mutate({
+ username: selectedUser.username,
+ role: editForm.role,
+ teamId: editForm.teamId,
+ });
};
- const handleToggleEnabled = async (user: User) => {
- try {
- await userManagementService.toggleUserEnabled(
- user.username,
- !user.enabled,
- );
- alert({
- alertType: "success",
- title: t("workspace.people.toggleEnabled.success"),
- });
- fetchData();
- } catch (error: unknown) {
- console.error("[PeopleSection] Failed to toggle user status:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t("workspace.people.toggleEnabled.error");
- alert({ alertType: "error", title: errorMessage });
- }
+ const handleToggleEnabled = (user: User) => {
+ toggleEnabled.mutate(user);
};
- const handleDeleteUser = async (user: User) => {
+ const handleDeleteUser = (user: User) => {
const confirmMessage = t(
"workspace.people.confirmDelete",
"Are you sure you want to delete this user? This action cannot be undone.",
);
- if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
- return;
- }
+ if (
+ !window.confirm(`${confirmMessage}
- try {
- await userManagementService.deleteUser(user.username);
- alert({
- alertType: "success",
- title: t(
- "workspace.people.deleteUserSuccess",
- "User deleted successfully",
- ),
- });
- fetchData();
- } catch (error: unknown) {
- console.error("[PeopleSection] Failed to delete user:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t("workspace.people.deleteUserError", "Failed to delete user");
- alert({ alertType: "error", title: errorMessage });
- }
+User: ${user.username}`)
+ )
+ return;
+ deleteUser.mutate(user.username);
};
- const handleUnlockUser = async (user: User) => {
+ const handleUnlockUser = (user: User) => {
const confirmMessage = t(
"workspace.people.confirmUnlock",
"Are you sure you want to unlock this user account?",
);
- if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
- return;
- }
+ if (
+ !window.confirm(`${confirmMessage}
- try {
- await userManagementService.unlockUser(user.username);
- alert({
- alertType: "success",
- title: t(
- "workspace.people.unlockUserSuccess",
- "User account unlocked successfully",
- ),
- });
- fetchData();
- } catch (error: unknown) {
- console.error("[PeopleSection] Failed to unlock user:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t(
- "workspace.people.unlockUserError",
- "Failed to unlock user account",
- );
- alert({ alertType: "error", title: errorMessage });
- }
+User: ${user.username}`)
+ )
+ return;
+ unlockUser.mutate(user.username);
};
const openEditModal = (user: User) => {
@@ -549,7 +489,7 @@ export default function PeopleSection() {
•
-
+
>
)}
@@ -891,40 +831,7 @@ export default function PeopleSection() {
height="1rem"
/>
}
- onClick={async () => {
- try {
- await userManagementService.disableMfaByAdmin(
- user.username,
- );
- alert({
- alertType: "success",
- title: t(
- "workspace.people.mfa.adminDisableSuccess",
- "MFA disabled successfully for user",
- ),
- });
- } catch (error: unknown) {
- console.error(
- "[PeopleSection] Failed to disable MFA for user:",
- error,
- );
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error
- ? error.message
- : undefined) ||
- t(
- "workspace.people.mfa.adminDisableError",
- "Failed to disable MFA for user",
- );
- alert({
- alertType: "error",
- title: errorMessage,
- });
- }
- }}
+ onClick={() => disableMfa.mutate(user.username)}
disabled={!loginEnabled}
>
{t(
@@ -968,14 +875,14 @@ export default function PeopleSection() {
setInviteModalOpened(false)}
- onSuccess={fetchData}
+ onSuccess={refreshDirectory}
/>
@@ -1075,7 +982,7 @@ export default function PeopleSection() {
/>
(null);
- const [teamUsers, setTeamUsers] = useState([]);
- const [availableUsers, setAvailableUsers] = useState([]);
- const [allTeams, setAllTeams] = useState([]);
- const [userLastRequest, setUserLastRequest] = useState<
- Record
- >({});
+ const details = useTeamDetails(teamId, true);
+ const admin = useAdminUsers(true);
+ // The same list TeamsSection is showing behind this view.
+ const { data: allTeams = [] } = useTeams(true);
+ const refreshDirectory = useInvalidateAdminDirectory();
+
+ const loading = details.isPending || admin.isPending;
+ const team = details.data?.team ?? null;
+ const teamUsers = Array.isArray(details.data?.teamUsers)
+ ? details.data.teamUsers
+ : [];
+ const availableUsers = Array.isArray(details.data?.availableUsers)
+ ? details.data.availableUsers
+ : [];
+ const userLastRequest = details.data?.userLastRequest ?? {};
+ const licenseInfo = admin.data
+ ? { availableSlots: admin.data.availableSlots }
+ : null;
+ const mailEnabled = admin.data?.mailEnabled ?? false;
+ const lockedUsers = admin.data?.lockedUsers ?? [];
+
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
const [changePasswordModalOpened, setChangePasswordModalOpened] =
@@ -53,68 +72,122 @@ export default function TeamDetailsSection({
const [selectedUser, setSelectedUser] = useState(null);
const [selectedUserId, setSelectedUserId] = useState("");
const [selectedTeamId, setSelectedTeamId] = useState("");
- const [processing, setProcessing] = useState(false);
const availableUsersForTeam = team
? availableUsers.filter((user) => user.team?.id !== team.id)
: [];
- // License information
- const [licenseInfo, setLicenseInfo] = useState<{
- availableSlots: number;
- } | null>(null);
- const [mailEnabled, setMailEnabled] = useState(false);
- const [lockedUsers, setLockedUsers] = useState([]);
-
const isLockedUser = (user: User) => lockedUsers.includes(user.username);
+ // A failed load leaves nothing to show, so the view hands back to the list.
+ const loadFailed = details.isLoadingError || admin.isLoadingError;
+ const reportedRef = useRef(false);
useEffect(() => {
- fetchTeamDetails();
- fetchAllTeams();
- }, [teamId]);
+ if (!loadFailed || reportedRef.current) return;
+ reportedRef.current = true;
+ alert({
+ alertType: "error",
+ title: t("workspace.teams.loadError", "Failed to load team details"),
+ });
+ onBack();
+ }, [loadFailed, onBack, t]);
- const fetchTeamDetails = async () => {
- try {
- setLoading(true);
- const [data, adminData] = await Promise.all([
- teamService.getTeamDetails(teamId),
- userManagementService.getUsers(),
- ]);
- console.log("[TeamDetailsSection] Raw data:", data);
- setTeam(data.team);
- setTeamUsers(Array.isArray(data.teamUsers) ? data.teamUsers : []);
- setAvailableUsers(
- Array.isArray(data.availableUsers) ? data.availableUsers : [],
+ // A membership move changes the team's count, the member's own team and
+ // both teams' detail rows.
+ const MEMBERSHIP = ["teams", "teamDetails", "users"] as const;
+
+ const addMember = useAdminMutation({
+ write: (userId: number) => teamService.addUserToTeam(teamId, userId),
+ invalidates: MEMBERSHIP,
+ success: t(
+ "workspace.teams.addMemberToTeam.success",
+ "User added to team successfully",
+ ),
+ errorFallback: t(
+ "workspace.teams.addMemberToTeam.error",
+ "Failed to add user to team",
+ ),
+ onDone: () => {
+ setAddMemberModalOpened(false);
+ setSelectedUserId("");
+ },
+ });
+
+ const removeMember = useAdminMutation({
+ write: (user: User) => {
+ const defaultTeam = allTeams.find((team) => team.name === "Default");
+ if (!defaultTeam) throw new Error("Default team not found");
+ return teamService.moveUserToTeam(
+ user.username,
+ user.rolesAsString || "ROLE_USER",
+ defaultTeam.id,
);
- setUserLastRequest(data.userLastRequest || {});
+ },
+ invalidates: MEMBERSHIP,
+ success: t("workspace.teams.removeMemberSuccess", "User removed from team"),
+ errorFallback: t(
+ "workspace.teams.removeMemberError",
+ "Failed to remove user from team",
+ ),
+ });
- // Store license information
- setLicenseInfo({
- availableSlots: adminData.availableSlots,
- });
- setMailEnabled(adminData.mailEnabled);
- setLockedUsers(adminData.lockedUsers || []);
- } catch (error) {
- console.error("Failed to fetch team details:", error);
- alert({
- alertType: "error",
- title: t("workspace.teams.loadError", "Failed to load team details"),
- });
- onBack();
- } finally {
- setLoading(false);
- }
- };
+ const changeTeam = useAdminMutation({
+ write: ({ user, teamId: target }: { user: User; teamId: number }) =>
+ teamService.moveUserToTeam(
+ user.username,
+ user.rolesAsString || "ROLE_USER",
+ target,
+ ),
+ invalidates: MEMBERSHIP,
+ success: t(
+ "workspace.teams.changeTeam.success",
+ "Team changed successfully",
+ ),
+ errorFallback: t(
+ "workspace.teams.changeTeam.error",
+ "Failed to change team",
+ ),
+ onDone: () => {
+ setChangeTeamModalOpened(false);
+ setSelectedUser(null);
+ setSelectedTeamId("");
+ },
+ });
- const fetchAllTeams = async () => {
- try {
- const teams = await teamService.getTeams();
- setAllTeams(teams);
- } catch (error) {
- console.error("Failed to fetch teams:", error);
- }
- };
+ const deleteUser = useAdminMutation({
+ write: (username: string) => userManagementService.deleteUser(username),
+ invalidates: ["users", "teams"],
+ success: t(
+ "workspace.people.deleteUserSuccess",
+ "User deleted successfully",
+ ),
+ errorFallback: t(
+ "workspace.people.deleteUserError",
+ "Failed to delete user",
+ ),
+ });
- const handleAddMember = async () => {
+ const unlockUser = useAdminMutation({
+ write: (username: string) => userManagementService.unlockUser(username),
+ invalidates: ["users"],
+ success: t(
+ "workspace.people.unlockUserSuccess",
+ "User account unlocked successfully",
+ ),
+ errorFallback: t(
+ "workspace.people.unlockUserError",
+ "Failed to unlock user account",
+ ),
+ });
+
+ // Row actions are blocked while any write is in flight, as before.
+ const processing =
+ addMember.isPending ||
+ removeMember.isPending ||
+ changeTeam.isPending ||
+ deleteUser.isPending ||
+ unlockUser.isPending;
+
+ const handleAddMember = () => {
if (!selectedUserId) {
alert({
alertType: "error",
@@ -125,155 +198,44 @@ export default function TeamDetailsSection({
});
return;
}
-
- try {
- setProcessing(true);
- await teamService.addUserToTeam(teamId, parseInt(selectedUserId));
- alert({
- alertType: "success",
- title: t(
- "workspace.teams.addMemberToTeam.success",
- "User added to team successfully",
- ),
- });
- setAddMemberModalOpened(false);
- setSelectedUserId("");
- fetchTeamDetails();
- } catch (error: unknown) {
- console.error("Failed to add member:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t(
- "workspace.teams.addMemberToTeam.error",
- "Failed to add user to team",
- );
- alert({ alertType: "error", title: errorMessage });
- } finally {
- setProcessing(false);
- }
+ addMember.mutate(parseInt(selectedUserId));
};
- const handleRemoveMember = async (user: User) => {
- if (
- !window.confirm(
- t(
- "workspace.teams.confirmRemove",
- `Remove ${user.username} from this team?`,
- ),
- )
- ) {
- return;
- }
-
- try {
- setProcessing(true);
- // Find the Default team ID
- const defaultTeam = allTeams.find((t) => t.name === "Default");
-
- if (!defaultTeam) {
- throw new Error("Default team not found");
- }
-
- // Move user to Default team by updating their role with the Default team ID
- await teamService.moveUserToTeam(
- user.username,
- user.rolesAsString || "ROLE_USER",
- defaultTeam.id,
- );
- alert({
- alertType: "success",
- title: t(
- "workspace.teams.removeMemberSuccess",
- "User removed from team",
- ),
- });
- fetchTeamDetails();
- } catch (error: unknown) {
- console.error("Failed to remove member:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t(
- "workspace.teams.removeMemberError",
- "Failed to remove user from team",
- );
- alert({ alertType: "error", title: errorMessage });
- } finally {
- setProcessing(false);
- }
+ const handleRemoveMember = (user: User) => {
+ const confirmMessage = t(
+ "workspace.teams.confirmRemove",
+ `Remove ${user.username} from this team?`,
+ );
+ if (!window.confirm(confirmMessage)) return;
+ removeMember.mutate(user);
};
- const handleDeleteUser = async (user: User) => {
+ const handleDeleteUser = (user: User) => {
const confirmMessage = t(
"workspace.people.confirmDelete",
"Are you sure you want to delete this user? This action cannot be undone.",
);
- if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
- return;
- }
+ if (
+ !window.confirm(`${confirmMessage}
- try {
- setProcessing(true);
- await userManagementService.deleteUser(user.username);
- alert({
- alertType: "success",
- title: t(
- "workspace.people.deleteUserSuccess",
- "User deleted successfully",
- ),
- });
- fetchTeamDetails();
- } catch (error: unknown) {
- console.error("Failed to delete user:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t("workspace.people.deleteUserError", "Failed to delete user");
- alert({ alertType: "error", title: errorMessage });
- } finally {
- setProcessing(false);
- }
+User: ${user.username}`)
+ )
+ return;
+ deleteUser.mutate(user.username);
};
- const handleUnlockUser = async (user: User) => {
+ const handleUnlockUser = (user: User) => {
const confirmMessage = t(
"workspace.people.confirmUnlock",
"Are you sure you want to unlock this user account?",
);
- if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
- return;
- }
+ if (
+ !window.confirm(`${confirmMessage}
- try {
- await userManagementService.unlockUser(user.username);
- alert({
- alertType: "success",
- title: t(
- "workspace.people.unlockUserSuccess",
- "User account unlocked successfully",
- ),
- });
- fetchTeamDetails();
- } catch (error: unknown) {
- console.error("[TeamDetailsSection] Failed to unlock user:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t(
- "workspace.people.unlockUserError",
- "Failed to unlock user account",
- );
- alert({ alertType: "error", title: errorMessage });
- }
+User: ${user.username}`)
+ )
+ return;
+ unlockUser.mutate(user.username);
};
const openChangeTeamModal = (user: User) => {
@@ -292,7 +254,7 @@ export default function TeamDetailsSection({
setPasswordUser(null);
};
- const handleChangeTeam = async () => {
+ const handleChangeTeam = () => {
if (!selectedUser || !selectedTeamId) {
alert({
alertType: "error",
@@ -303,37 +265,10 @@ export default function TeamDetailsSection({
});
return;
}
-
- try {
- setProcessing(true);
- await teamService.moveUserToTeam(
- selectedUser.username,
- selectedUser.rolesAsString || "ROLE_USER",
- parseInt(selectedTeamId),
- );
- alert({
- alertType: "success",
- title: t(
- "workspace.teams.changeTeam.success",
- "Team changed successfully",
- ),
- });
- setChangeTeamModalOpened(false);
- setSelectedUser(null);
- setSelectedTeamId("");
- fetchTeamDetails();
- } catch (error: unknown) {
- console.error("Failed to change team:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t("workspace.teams.changeTeam.error", "Failed to change team");
- alert({ alertType: "error", title: errorMessage });
- } finally {
- setProcessing(false);
- }
+ changeTeam.mutate({
+ user: selectedUser,
+ teamId: parseInt(selectedTeamId),
+ });
};
if (loading) {
@@ -686,7 +621,7 @@ export default function TeamDetailsSection({
opened={changePasswordModalOpened}
onClose={closeChangePasswordModal}
user={passwordUser}
- onSuccess={fetchTeamDetails}
+ onSuccess={refreshDirectory}
mailEnabled={mailEnabled}
/>
@@ -762,7 +697,7 @@ export default function TeamDetailsSection({
([]);
- const [loading, setLoading] = useState(true);
+ const { data: fetchedTeams, isPending } = useTeams(loginEnabled);
+ const fetchAdminUsers = useFetchAdminUsers();
+ // Login off means the endpoints are not callable, so the table shows a
+ // worked example instead of an empty state.
+ const teams = loginEnabled ? (fetchedTeams ?? []) : EXAMPLE_TEAMS;
+ const loading = loginEnabled && isPending;
const [createModalOpened, setCreateModalOpened] = useState(false);
const [renameModalOpened, setRenameModalOpened] = useState(false);
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
const [selectedTeam, setSelectedTeam] = useState(null);
const [availableUsers, setAvailableUsers] = useState([]);
- const [processing, setProcessing] = useState(false);
const [viewingTeamId, setViewingTeamId] = useState(null);
// Form states
@@ -49,34 +59,53 @@ export default function TeamsSection() {
? availableUsers.filter((user) => user.team?.id !== selectedTeam.id)
: [];
- useEffect(() => {
- fetchTeams();
- }, []);
+ const createTeam = useAdminMutation({
+ write: (name: string) => teamService.createTeam(name),
+ invalidates: ["teams"],
+ success: t("workspace.teams.createTeam.success"),
+ errorFallback: t("workspace.teams.createTeam.error"),
+ onDone: () => {
+ setNewTeamName("");
+ setCreateModalOpened(false);
+ },
+ });
- const fetchTeams = async () => {
- try {
- setLoading(true);
- if (loginEnabled) {
- const teamsData = await teamService.getTeams();
- setTeams(teamsData);
- } else {
- // Provide example data when login is disabled
- const exampleTeams: Team[] = [
- { id: 1, name: "Engineering", userCount: 3 },
- { id: 2, name: "Marketing", userCount: 2 },
- { id: 3, name: "Internal", userCount: 1 },
- ];
- setTeams(exampleTeams);
- }
- } catch (error) {
- console.error("Failed to fetch teams:", error);
- alert({ alertType: "error", title: "Failed to load teams" });
- } finally {
- setLoading(false);
- }
- };
+ const renameTeam = useAdminMutation({
+ write: ({ id, name }: { id: number; name: string }) =>
+ teamService.renameTeam(id, name),
+ invalidates: ["teams"],
+ success: t("workspace.teams.renameTeam.success"),
+ errorFallback: t("workspace.teams.renameTeam.error"),
+ onDone: () => {
+ setRenameTeamName("");
+ setSelectedTeam(null);
+ setRenameModalOpened(false);
+ },
+ });
- const handleCreateTeam = async () => {
+ const deleteTeam = useAdminMutation({
+ write: (id: number) => teamService.deleteTeam(id),
+ invalidates: ["teams"],
+ success: t("workspace.teams.deleteTeam.success"),
+ errorFallback: t("workspace.teams.deleteTeam.error"),
+ });
+
+ // Membership changes a team's count, the member's own team, and both
+ // teams' detail rows.
+ const addMember = useAdminMutation({
+ write: ({ teamId, userId }: { teamId: number; userId: number }) =>
+ teamService.addUserToTeam(teamId, userId),
+ invalidates: ["teams", "teamDetails", "users"],
+ success: t("workspace.teams.addMemberToTeam.success"),
+ errorFallback: t("workspace.teams.addMemberToTeam.error"),
+ onDone: () => {
+ setSelectedUserId("");
+ setSelectedTeam(null);
+ setAddMemberModalOpened(false);
+ },
+ });
+
+ const handleCreateTeam = () => {
if (!newTeamName.trim()) {
alert({
alertType: "error",
@@ -84,32 +113,10 @@ export default function TeamsSection() {
});
return;
}
-
- try {
- setProcessing(true);
- await teamService.createTeam(newTeamName);
- alert({
- alertType: "success",
- title: t("workspace.teams.createTeam.success"),
- });
- setNewTeamName("");
- setCreateModalOpened(false);
- await fetchTeams();
- } catch (error: unknown) {
- console.error("Failed to create team:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t("workspace.teams.createTeam.error");
- alert({ alertType: "error", title: errorMessage });
- } finally {
- setProcessing(false);
- }
+ createTeam.mutate(newTeamName);
};
- const handleRenameTeam = async () => {
+ const handleRenameTeam = () => {
if (!selectedTeam || !renameTeamName.trim()) {
alert({
alertType: "error",
@@ -117,33 +124,10 @@ export default function TeamsSection() {
});
return;
}
-
- try {
- setProcessing(true);
- await teamService.renameTeam(selectedTeam.id, renameTeamName);
- alert({
- alertType: "success",
- title: t("workspace.teams.renameTeam.success"),
- });
- setRenameTeamName("");
- setSelectedTeam(null);
- setRenameModalOpened(false);
- await fetchTeams();
- } catch (error: unknown) {
- console.error("Failed to rename team:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t("workspace.teams.renameTeam.error");
- alert({ alertType: "error", title: errorMessage });
- } finally {
- setProcessing(false);
- }
+ renameTeam.mutate({ id: selectedTeam.id, name: renameTeamName });
};
- const handleDeleteTeam = async (team: Team) => {
+ const handleDeleteTeam = (team: Team) => {
if (team.name === "Internal") {
alert({
alertType: "error",
@@ -151,28 +135,8 @@ export default function TeamsSection() {
});
return;
}
-
- if (!confirm(t("workspace.teams.confirmDelete"))) {
- return;
- }
-
- try {
- await teamService.deleteTeam(team.id);
- alert({
- alertType: "success",
- title: t("workspace.teams.deleteTeam.success"),
- });
- await fetchTeams();
- } catch (error: unknown) {
- console.error("Failed to delete team:", error);
- const errorMessage = isAxiosError(error)
- ? error.response?.data?.message ||
- error.response?.data?.error ||
- error.message
- : (error instanceof Error ? error.message : undefined) ||
- t("workspace.teams.deleteTeam.error");
- alert({ alertType: "error", title: errorMessage });
- }
+ if (!confirm(t("workspace.teams.confirmDelete"))) return;
+ deleteTeam.mutate(team.id);
};
const openRenameModal = (team: Team) => {
@@ -198,8 +162,7 @@ export default function TeamsSection() {
}
setSelectedTeam(team);
try {
- // Fetch all users to show in dropdown
- const adminData = await userManagementService.getUsers();
+ const adminData = await fetchAdminUsers();
setAvailableUsers(adminData.users);
setAddMemberModalOpened(true);
} catch (error) {
@@ -211,7 +174,7 @@ export default function TeamsSection() {
}
};
- const handleAddMember = async () => {
+ const handleAddMember = () => {
if (!selectedTeam || !selectedUserId) {
alert({
alertType: "error",
@@ -219,30 +182,10 @@ export default function TeamsSection() {
});
return;
}
-
- try {
- setProcessing(true);
- await teamService.addUserToTeam(
- selectedTeam.id,
- parseInt(selectedUserId),
- );
- alert({
- alertType: "success",
- title: t("workspace.teams.addMemberToTeam.success"),
- });
- setSelectedUserId("");
- setSelectedTeam(null);
- setAddMemberModalOpened(false);
- await fetchTeams();
- } catch (error) {
- console.error("Failed to add member to team:", error);
- alert({
- alertType: "error",
- title: t("workspace.teams.addMemberToTeam.error"),
- });
- } finally {
- setProcessing(false);
- }
+ addMember.mutate({
+ teamId: selectedTeam.id,
+ userId: parseInt(selectedUserId),
+ });
};
// If viewing team details, render TeamDetailsSection
@@ -252,7 +195,6 @@ export default function TeamsSection() {
teamId={viewingTeamId}
onBack={() => {
setViewingTeamId(null);
- fetchTeams(); // Refresh teams list
}}
/>
);
@@ -493,7 +435,7 @@ export default function TeamsSection() {
({ alert: vi.fn() }));
+import { alert } from "@app/components/toast";
+import { allowConsole } from "@app/tests/failOnConsole";
+vi.mock("react-router-dom", () => ({ useNavigate: () => vi.fn() }));
+vi.mock("@app/auth/UseSession", () => ({
+ useAuth: () => ({ user: { username: "admin" } }),
+}));
+vi.mock("@app/contexts/LicenseContext", () => ({
+ useLicense: () => ({ licenseInfo: null }),
+}));
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (k: string, f?: unknown) => (typeof f === "string" ? f : k),
+ }),
+ Trans: ({ children }: { children?: ReactNode }) => children ?? null,
+}));
+
+const calls = { getTeams: 0, getUsers: 0, getTeamDetails: 0 };
+let client: QueryClient;
+
+const TEAMS: Team[] = [
+ { id: 1, name: "Engineering", userCount: 8 },
+ { id: 2, name: "Marketing", userCount: 3 },
+];
+
+const ADMIN_DATA: AdminSettingsData = {
+ users: [
+ {
+ id: 1,
+ username: "alice",
+ email: "alice@example.com",
+ enabled: true,
+ roleName: "ROLE_ADMIN",
+ rolesAsString: "ROLE_ADMIN",
+ authenticationType: "password",
+ },
+ ],
+ userSessions: {},
+ userLastRequest: {},
+ totalUsers: 1,
+ activeUsers: 1,
+ disabledUsers: 0,
+ maxAllowedUsers: 10,
+ availableSlots: 9,
+ grandfatheredUserCount: 0,
+ licenseMaxUsers: 10,
+ premiumEnabled: true,
+ mailEnabled: false,
+ userSettings: {},
+ lockedUsers: [],
+};
+
+const TEAM_DETAILS: TeamDetailsUIResponse = {
+ team: { id: 1, name: "Engineering" },
+ teamUsers: [
+ {
+ id: 1,
+ username: "alice",
+ enabled: true,
+ roleName: "ROLE_ADMIN",
+ rolesAsString: "ROLE_ADMIN",
+ authenticationType: "password",
+ },
+ ],
+ availableUsers: [],
+ userLastRequest: {},
+};
+
+let teamsPayload: Team[] = TEAMS;
+
+function stubServices() {
+ teamService.getTeams = async () => {
+ calls.getTeams++;
+ return teamsPayload;
+ };
+ teamService.getTeamDetails = async () => {
+ calls.getTeamDetails++;
+ return TEAM_DETAILS;
+ };
+ userManagementService.getUsers = async () => {
+ calls.getUsers++;
+ return ADMIN_DATA;
+ };
+}
+
+function Harness({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+function totalRequests() {
+ return calls.getTeams + calls.getUsers + calls.getTeamDetails;
+}
+
+describe("admin directory reads", () => {
+ beforeEach(() => {
+ calls.getTeams = 0;
+ calls.getUsers = 0;
+ calls.getTeamDetails = 0;
+ teamsPayload = TEAMS;
+ // Mirrors the app client, so the stale window under test is the real one.
+ client = new QueryClient({
+ defaultOptions: { queries: { ...baseQueryOptions, retry: false } },
+ });
+ stubServices();
+ });
+
+ it("costs three requests for teams -> details -> back -> people", async () => {
+ const user = userEvent.setup();
+ const teamsView = render(
+
+
+ ,
+ );
+ await screen.findByText("Engineering");
+
+ await user.click(screen.getByText("Engineering"));
+ await waitFor(() => expect(calls.getTeamDetails).toBe(1));
+ await act(async () => {});
+
+ await user.click(screen.getByRole("button", { name: /back/i }));
+ await screen.findByText("Marketing");
+
+ // Config sections are swapped, not stacked: changing tab unmounts one.
+ teamsView.unmount();
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(calls.getUsers).toBe(1));
+ await act(async () => {});
+
+ // One fetch per distinct resource. The team list is read by all three
+ // views and the roster by two, so both were previously fetched per view.
+ expect(calls.getTeams).toBe(1);
+ expect(calls.getUsers).toBe(1);
+ expect(calls.getTeamDetails).toBe(1);
+ expect(totalRequests()).toBe(3);
+ });
+
+ it("shows the team list a write produced, without a manual refresh call", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ await screen.findByText("Engineering");
+
+ // The write lands server-side; only an invalidation brings it back.
+ teamService.createTeam = async () => {
+ teamsPayload = [...TEAMS, { id: 3, name: "Platform", userCount: 0 }];
+ };
+
+ await user.click(
+ screen.getByRole("button", { name: "workspace.teams.createNewTeam" }),
+ );
+ await user.type(
+ await screen.findByPlaceholderText(
+ "workspace.teams.createTeam.teamNamePlaceholder",
+ ),
+ "Platform",
+ );
+ await user.click(
+ screen.getByRole("button", {
+ name: "workspace.teams.createTeam.submit",
+ }),
+ );
+
+ await screen.findByText("Platform");
+ });
+
+ it("makes no request and shows example data when login is disabled", async () => {
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ // Example rows, not a spinner: the endpoints are not callable.
+ await screen.findByText("Internal");
+ expect(calls.getTeams).toBe(0);
+ });
+
+ it("refreshes the roster after disabling a user's MFA", async () => {
+ const user = userEvent.setup();
+ let mfa = "true";
+ userManagementService.getUsers = async () => {
+ calls.getUsers++;
+ return {
+ ...ADMIN_DATA,
+ userSettings: { alice: { mfaEnabled: mfa } },
+ };
+ };
+ userManagementService.disableMfaByAdmin = async () => {
+ mfa = "false";
+ };
+
+ render(
+
+
+ ,
+ );
+ await screen.findByText("alice");
+
+ await user.click(screen.getByLabelText("Member actions"));
+ await user.click(await screen.findByText("Disable MFA"));
+
+ // The row drove the menu item off itself: it must reflect the new state
+ // without a reload.
+ await waitFor(() => expect(calls.getUsers).toBe(2));
+ });
+
+ it("reports the server's refusal message, not a generic one", async () => {
+ const user = userEvent.setup();
+ allowConsole.error(/Admin directory write failed/);
+ teamService.createTeam = async () => {
+ throw Object.assign(new Error("Request failed"), {
+ isAxiosError: true,
+ response: { data: { message: "A team with that name exists" } },
+ });
+ };
+
+ render(
+
+
+ ,
+ );
+ await screen.findByText("Engineering");
+
+ await user.click(
+ screen.getByRole("button", { name: "workspace.teams.createNewTeam" }),
+ );
+ await user.type(
+ await screen.findByPlaceholderText(
+ "workspace.teams.createTeam.teamNamePlaceholder",
+ ),
+ "Engineering",
+ );
+ await user.click(
+ screen.getByRole("button", {
+ name: "workspace.teams.createTeam.submit",
+ }),
+ );
+
+ await waitFor(() =>
+ expect(alert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ alertType: "error",
+ title: "A team with that name exists",
+ }),
+ ),
+ );
+ });
+});
diff --git a/frontend/editor/src/proprietary/hooks/useAdminDirectory.ts b/frontend/editor/src/proprietary/hooks/useAdminDirectory.ts
new file mode 100644
index 0000000000..291e1e85df
--- /dev/null
+++ b/frontend/editor/src/proprietary/hooks/useAdminDirectory.ts
@@ -0,0 +1,151 @@
+import { useCallback } from "react";
+import { isAxiosError } from "axios";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { alert } from "@app/components/toast";
+import { qk } from "@app/query/keys";
+import {
+ userManagementService,
+ type AdminSettingsData,
+} from "@app/services/userManagementService";
+import {
+ teamService,
+ type Team,
+ type TeamDetailsUIResponse,
+} from "@app/services/teamService";
+
+/**
+ * The people and teams an admin screen reads and writes. Three sections read
+ * overlapping slices of it, so they share these keys rather than each holding
+ * a copy, and each write says which slices it invalidates.
+ *
+ * `enabled` is the login-enabled gate: with login off the endpoints are not
+ * callable and the sections render example data instead.
+ */
+export function useAdminUsers(enabled: boolean) {
+ return useQuery({
+ queryKey: qk.adminUsers(),
+ queryFn: () => userManagementService.getUsers(),
+ enabled,
+ });
+}
+
+export function useTeams(enabled: boolean) {
+ return useQuery({
+ queryKey: qk.teams(),
+ queryFn: () => teamService.getTeams(),
+ enabled,
+ });
+}
+
+export function useTeamDetails(teamId: number, enabled: boolean) {
+ return useQuery({
+ queryKey: qk.teamDetails(teamId),
+ queryFn: () => teamService.getTeamDetails(teamId),
+ enabled,
+ });
+}
+
+/**
+ * Imperative read, for flows that fetch before opening a modal. Serves the
+ * same cache entry the sections render from, so an already-loaded directory
+ * costs nothing.
+ */
+export function useFetchAdminUsers() {
+ const queryClient = useQueryClient();
+ return useCallback(
+ () =>
+ queryClient.fetchQuery({
+ queryKey: qk.adminUsers(),
+ queryFn: () => userManagementService.getUsers(),
+ }),
+ [queryClient],
+ );
+}
+
+/** Which slices of the directory a write disturbs. */
+export type DirectoryScope = "users" | "teams" | "teamDetails";
+
+const SCOPE_KEYS: Record = {
+ users: qk.adminUsers(),
+ teams: qk.teams(),
+ // Prefix, not one id: a membership move changes two teams' detail rows.
+ teamDetails: ["editor", "teamDetails"],
+};
+
+/**
+ * Blanket invalidation, for child components that write through their own
+ * services (invites, password changes, seat updates). The scopes those touch
+ * are not visible from here, so they refresh everything.
+ */
+export function useInvalidateAdminDirectory() {
+ const invalidate = useInvalidateScopes();
+ return useCallback(
+ () => invalidate(["users", "teams", "teamDetails"]),
+ [invalidate],
+ );
+}
+
+function useInvalidateScopes() {
+ const queryClient = useQueryClient();
+ return useCallback(
+ (scopes: readonly DirectoryScope[]) => {
+ for (const scope of scopes) {
+ queryClient.invalidateQueries({ queryKey: SCOPE_KEYS[scope] });
+ }
+ },
+ [queryClient],
+ );
+}
+
+/** The server's message if it sent one, since it explains the refusal. */
+export function adminErrorMessage(error: unknown, fallback: string): string {
+ if (isAxiosError(error)) {
+ return (
+ error.response?.data?.message ||
+ error.response?.data?.error ||
+ error.message ||
+ fallback
+ );
+ }
+ return (error instanceof Error ? error.message : undefined) || fallback;
+}
+
+interface AdminMutationOptions {
+ write: (args: TArgs) => Promise;
+ invalidates: readonly DirectoryScope[];
+ success: string;
+ errorFallback: string;
+ /** Local state to clear once the write lands, such as closing its modal. */
+ onDone?: () => void;
+}
+
+/**
+ * One directory write: toasts the outcome, refreshes the slices it changed,
+ * and exposes `isPending` for the button that triggered it. All thirteen
+ * call sites did this by hand, and one of them forgot the refresh.
+ */
+export function useAdminMutation({
+ write,
+ invalidates,
+ success,
+ errorFallback,
+ onDone,
+}: AdminMutationOptions) {
+ const invalidate = useInvalidateScopes();
+ return useMutation({
+ mutationFn: write,
+ onSuccess: () => {
+ alert({ alertType: "success", title: success });
+ invalidate(invalidates);
+ onDone?.();
+ },
+ onError: (error) => {
+ // The toast carries the server's wording; the console keeps the cause.
+ console.error("Admin directory write failed:", error);
+ alert({
+ alertType: "error",
+ title: adminErrorMessage(error, errorFallback),
+ });
+ },
+ });
+}
From ead8a536d2db2f6d0696864a1b1482061b363f2c Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Sat, 29 Aug 2026 10:35:40 +0000
Subject: [PATCH 17/27] feat(editor): move signing sessions onto TanStack Query
(#7436)
# Description of Changes
Step 4 of the TanStack Query rollout, and the first of the polling
hooks. Follows #7264, #7283, #7285.
## The problem
`useSigningSessions` hand-rolled its own fetch, loading state and
`setInterval`. Two consequences:
- **A raw `setInterval` keeps polling a hidden tab.** Browsers throttle
background timers, they do not stop them, so a backgrounded editor with
Shared Sign open keeps hitting both endpoints for as long as it is open.
- **No tests.** The hook had none, and its quietest behaviour (below) is
the easiest thing to break without noticing.
## End state
One query behind `qk.signingSessions()`, with the polling lifecycle
handed to the library:
- Polling stops while the tab is hidden, and refetches on return rather
than leaving data up to a full interval stale.
- Mounts render from cache while they revalidate, so moving between the
tool picker and the signing tool no longer flashes an empty list.
- 12 tests where there were none.
Same return shape, so no consumer files change.
### What this is not
This is not a deduplication win. The three consumers are never mounted
at the same time: `ToolPanel` renders the tool picker or the active tool
and never both, so the badge cannot be on screen with either of the
others, and `SharedSigningLauncher` and `useSigningSessionController`
sit inside two different tools. The shared key earns its keep on cache
reuse across those transitions, not on concurrent fetches.
## The bit worth reviewing
The hand-rolled `{ silent: true }` flag encoded three states, and no
single Query flag reproduces them:
| | Spinner | Toast on failure |
|---|---|---|
| First load | yes | yes |
| Background poll | no | no |
| Explicit refetch | **yes** | **yes** |
`isLoading` is false during an explicit refetch when data is already on
screen; `isFetching` is true during a background poll. Neither matches,
so the user-initiated case is tracked with a small flag and the failure
toast is gated on `isLoadingError` plus the explicit path.
## Testing
Twelve tests. Rather than trust them, each claim was checked by breaking
the implementation and confirming the relevant test fails:
| Mutation | Caught by |
|---|---|
| `refetchIntervalInBackground: true` | hidden-tab test |
| Drop `refetchOnWindowFocus` | returns-to-view test |
| Drop the user-initiated spinner flag | manual-refresh test |
| Toast on every error | background-failure-is-silent test |
| Give each observer its own key | dedupe test |
Three things worth knowing for the next conversion:
- **`waitFor` flushes renders.** Recording an index *after*
`waitFor(callCount === 2)` skips past the in-flight render, so a "did
the spinner flip on" assertion passes vacuously. The marker has to go
before the poll.
- **Fake timers hide in-flight state.** The fetch settles inside the
same `act()`, so the intermediate render never happens. That test uses
real timers and a held-open promise.
- **`visibilitychange` has to bubble.** query-core listens for it on
`window`, and the real event bubbles from `document`. A test helper
dispatching a non-bubbling event never reaches the focus manager, and
the pause behaviour still appears to work because `refetchInterval`
reads `document.visibilityState` directly at tick time rather than
through the event.
**One claim is deliberately unguarded.** `isLoading` vs `isFetching` for
a background poll produces no re-render at all, so there is nothing
observable for a test to assert and no user-visible difference to
protect.
## Pre-existing failures
`task frontend:check` passes typecheck, lint and oxfmt, and 2363 of 2365
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, fail identically with this branch's
changes reverted and are untouched by it.
## Scope
This is one of five pollers. The remaining four, `useLocalFolderPoller`,
`WatchedFolderWorkbenchView`, `SessionDetailPanel` and cloud
`TeamSection`, are separate files with their own consumers and follow
separately, now that the silent-refresh pattern has a worked example.
---------
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
---
frontend/editor/src/core/api/signing.ts | 21 ++
.../hooks/signing/useSigningSessions.test.tsx | 319 ++++++++++++++++++
.../core/hooks/signing/useSigningSessions.ts | 132 ++++----
frontend/editor/src/core/query/keys.ts | 1 +
4 files changed, 400 insertions(+), 73 deletions(-)
create mode 100644 frontend/editor/src/core/api/signing.ts
create mode 100644 frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx
diff --git a/frontend/editor/src/core/api/signing.ts b/frontend/editor/src/core/api/signing.ts
new file mode 100644
index 0000000000..c58fad8aac
--- /dev/null
+++ b/frontend/editor/src/core/api/signing.ts
@@ -0,0 +1,21 @@
+import apiClient from "@app/services/apiClient";
+import type {
+ SignRequestSummary,
+ SessionSummary,
+} from "@app/types/signingSession";
+
+export interface SigningSessions {
+ signRequests: SignRequestSummary[];
+ mySessions: SessionSummary[];
+}
+
+/** The two lists the signing UI always needs together. */
+export async function fetchSigningSessions(): Promise {
+ const [requests, sessions] = await Promise.all([
+ apiClient.get(
+ "/api/v1/security/cert-sign/sign-requests",
+ ),
+ apiClient.get("/api/v1/security/cert-sign/sessions"),
+ ]);
+ return { signRequests: requests.data, mySessions: sessions.data };
+}
diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx b/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx
new file mode 100644
index 0000000000..14e473e33c
--- /dev/null
+++ b/frontend/editor/src/core/hooks/signing/useSigningSessions.test.tsx
@@ -0,0 +1,319 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { baseQueryOptions } from "@app/query/queryClient";
+import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
+import { useSigningSessions } from "@app/hooks/signing/useSigningSessions";
+import { fetchSigningSessions } from "@app/api/signing";
+import { alert } from "@app/components/toast";
+import { expectConsole } from "@app/tests/failOnConsole";
+
+vi.mock("@app/api/signing", () => ({ fetchSigningSessions: vi.fn() }));
+vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (_k: string, fallback?: string) => fallback ?? _k,
+ }),
+}));
+
+const mockFetch = vi.mocked(fetchSigningSessions);
+const mockAlert = vi.mocked(alert);
+
+const EMPTY = { signRequests: [], mySessions: [] };
+
+function setVisibility(state: "visible" | "hidden") {
+ Object.defineProperty(document, "visibilityState", {
+ configurable: true,
+ get: () => state,
+ });
+ // Bubbles, as the real event does: query-core listens for it on window.
+ document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
+}
+
+describe("useSigningSessions", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetch.mockResolvedValue(EMPTY);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ setVisibility("visible");
+ });
+
+ it("dedupes concurrent observers of the same key", async () => {
+ const { result } = renderHook(
+ () => ({
+ badge: useSigningSessions({
+ enabled: true,
+ autoRefreshInterval: 60000,
+ }),
+ launcher: useSigningSessions({ enabled: true }),
+ controller: useSigningSessions({
+ enabled: true,
+ autoRefreshInterval: 15000,
+ }),
+ }),
+ { wrapper: TestQueryProvider },
+ );
+
+ await waitFor(() => expect(result.current.badge.loading).toBe(false));
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not fetch while disabled", async () => {
+ vi.useFakeTimers();
+ const { result } = renderHook(
+ () => useSigningSessions({ enabled: false, autoRefreshInterval: 15000 }),
+ { wrapper: TestQueryProvider },
+ );
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ await act(async () => {
+ vi.advanceTimersByTime(60000);
+ });
+ expect(mockFetch).not.toHaveBeenCalled();
+ expect(result.current.signRequests).toEqual([]);
+ });
+
+ it("starts fetching when enabled flips on", async () => {
+ const { result, rerender } = renderHook(
+ ({ on }: { on: boolean }) => useSigningSessions({ enabled: on }),
+ { wrapper: TestQueryProvider, initialProps: { on: false } },
+ );
+
+ expect(mockFetch).not.toHaveBeenCalled();
+ rerender({ on: true });
+ await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ });
+
+ it("polls on the interval", async () => {
+ vi.useFakeTimers();
+ const { result } = renderHook(
+ () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
+ { wrapper: TestQueryProvider },
+ );
+
+ expect(result.current.loading).toBe(true);
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(15000);
+ });
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not raise the spinner while a background poll is in flight", async () => {
+ // Real timers, a held-open poll, and every render recorded. Asserting on
+ // result.current alone is not enough: waitFor returns as soon as the fetch
+ // count moves, before React has re-rendered, so a spinner that did flip on
+ // would be missed.
+ const seen: boolean[] = [];
+ const { result } = renderHook(
+ () => {
+ const state = useSigningSessions({
+ enabled: true,
+ autoRefreshInterval: 50,
+ });
+ seen.push(state.loading);
+ return state;
+ },
+ { wrapper: TestQueryProvider },
+ );
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ // Marked before the poll: waitFor flushes renders, so recording after it
+ // would skip straight past the in-flight one.
+ const fromPollStart = seen.length;
+
+ let release: (v: unknown) => void = () => {};
+ mockFetch.mockReturnValueOnce(
+ new Promise((resolve) => {
+ release = resolve;
+ }) as never,
+ );
+
+ await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
+
+ // Give React room to render the in-flight state, if it produces one.
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ });
+
+ // Mid-poll: this is what the old `silent` flag bought.
+ expect(seen.slice(fromPollStart)).not.toContain(true);
+ expect(result.current.loading).toBe(false);
+
+ await act(async () => {
+ release(EMPTY);
+ });
+ });
+
+ it("shows the spinner for a user-initiated refresh, not a background poll", async () => {
+ // Real timers: the in-flight window has to be observable, which is exactly
+ // what a fake-timer act() hides.
+ const { result } = renderHook(() => useSigningSessions({ enabled: true }), {
+ wrapper: TestQueryProvider,
+ });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ let release: (v: unknown) => void = () => {};
+ mockFetch.mockReturnValueOnce(
+ new Promise((resolve) => {
+ release = resolve;
+ }) as never,
+ );
+
+ let done: Promise;
+ act(() => {
+ done = result.current.refetch();
+ });
+ await waitFor(() => expect(result.current.loading).toBe(true));
+
+ await act(async () => {
+ release(EMPTY);
+ await done;
+ });
+ expect(result.current.loading).toBe(false);
+ });
+
+ it("toasts a first-load failure", async () => {
+ expectConsole.error(/Failed to fetch signing data/);
+ mockFetch.mockRejectedValue(new Error("down"));
+
+ const { result } = renderHook(() => useSigningSessions({ enabled: true }), {
+ wrapper: TestQueryProvider,
+ });
+
+ await waitFor(() => expect(result.current.error).toBeTruthy());
+ expect(mockAlert).toHaveBeenCalledTimes(1);
+ });
+
+ it("stays silent when a background poll fails after a success", async () => {
+ vi.useFakeTimers();
+ mockFetch.mockResolvedValueOnce(EMPTY);
+
+ const { result } = renderHook(
+ () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
+ { wrapper: TestQueryProvider },
+ );
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(result.current.loading).toBe(false);
+ expect(mockAlert).not.toHaveBeenCalled();
+
+ mockFetch.mockRejectedValue(new Error("flaky"));
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(15000);
+ });
+
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ expect(mockAlert).not.toHaveBeenCalled();
+ });
+
+ it("toasts an explicit refetch failure even with data on screen", async () => {
+ expectConsole.error(/Failed to fetch signing data/);
+ const { result } = renderHook(() => useSigningSessions({ enabled: true }), {
+ wrapper: TestQueryProvider,
+ });
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(mockAlert).not.toHaveBeenCalled();
+
+ mockFetch.mockRejectedValue(new Error("nope"));
+ await act(async () => {
+ await result.current.refetch();
+ });
+
+ expect(mockAlert).toHaveBeenCalledTimes(1);
+ });
+
+ it("stops polling while the tab is hidden", async () => {
+ vi.useFakeTimers();
+ const { result } = renderHook(
+ () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
+ { wrapper: TestQueryProvider },
+ );
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(result.current.loading).toBe(false);
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ setVisibility("hidden");
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(60000);
+ });
+ // Four intervals elapsed with the tab in the background.
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ setVisibility("visible");
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(15000);
+ });
+ expect(mockFetch.mock.calls.length).toBeGreaterThan(1);
+ });
+
+ it("refetches on becoming visible rather than waiting out the interval", async () => {
+ vi.useFakeTimers();
+ // The app client turns focus refetching off globally; TestQueryProvider
+ // does not, and would pass this on the library default alone.
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: { ...baseQueryOptions, retry: false, gcTime: Infinity },
+ },
+ });
+ const { result } = renderHook(
+ () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
+ {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ },
+ );
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(result.current.loading).toBe(false);
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ setVisibility("hidden");
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(60000);
+ });
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ setVisibility("visible");
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ });
+
+ it("stops polling once unmounted", async () => {
+ vi.useFakeTimers();
+ const { unmount } = renderHook(
+ () => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
+ { wrapper: TestQueryProvider },
+ );
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+
+ unmount();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(60000);
+ });
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessions.ts b/frontend/editor/src/core/hooks/signing/useSigningSessions.ts
index 785e792414..e001af5441 100644
--- a/frontend/editor/src/core/hooks/signing/useSigningSessions.ts
+++ b/frontend/editor/src/core/hooks/signing/useSigningSessions.ts
@@ -1,9 +1,14 @@
-import { useState, useCallback, useEffect } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
-import apiClient from "@app/services/apiClient";
+import { fetchSigningSessions } from "@app/api/signing";
+import { qk } from "@app/query/keys";
import { alert } from "@app/components/toast";
import { SignRequestSummary, SessionSummary } from "@app/types/signingSession";
+const EMPTY_REQUESTS: SignRequestSummary[] = [];
+const EMPTY_SESSIONS: SessionSummary[] = [];
+
export interface UseSigningSessionsOptions {
enabled?: boolean;
autoRefreshInterval?: number; // milliseconds, 0 to disable
@@ -18,8 +23,8 @@ export interface UseSigningSessionsResult {
}
/**
- * Hook to fetch signing sessions data (sign requests and user's sessions).
- * Supports auto-refresh for real-time updates.
+ * Signing sessions. Background polls never raise the spinner or a toast; only a
+ * first load or an explicit refetch does.
*/
export const useSigningSessions = (
options: UseSigningSessionsOptions = {},
@@ -27,83 +32,64 @@ export const useSigningSessions = (
const { enabled = true, autoRefreshInterval = 0 } = options;
const { t } = useTranslation();
- const [signRequests, setSignRequests] = useState([]);
- const [mySessions, setMySessions] = useState([]);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
+ const { data, isLoading, isLoadingError, error, refetch } = useQuery({
+ queryKey: qk.signingSessions(),
+ queryFn: fetchSigningSessions,
+ enabled,
+ staleTime: 0,
+ refetchInterval: autoRefreshInterval > 0 ? autoRefreshInterval : false,
+ refetchIntervalInBackground: false,
+ // The interval pauses while unfocused, so returning has to catch up: the
+ // client-wide default of false would hold stale data until the next tick.
+ refetchOnWindowFocus: autoRefreshInterval > 0,
+ });
- const fetchData = useCallback(
- async (opts?: { silent?: boolean }) => {
- if (!enabled) return;
+ const notifyFailure = useCallback(() => {
+ console.error("Failed to fetch signing data");
+ alert({
+ alertType: "warning",
+ title: t("common.error"),
+ body: t("certSign.fetchFailed", "Failed to load signing data"),
+ expandable: false,
+ durationMs: 2500,
+ });
+ }, [t]);
- // Background auto-refreshes pass { silent: true } to skip the loading spinner
- // and failure toasts; only the initial load and explicit refetch surface errors.
- const silent = opts?.silent ?? false;
-
- if (!silent) setLoading(true);
- setError(null);
-
- try {
- const [requestsResponse, sessionsResponse] = await Promise.all([
- apiClient.get(
- "/api/v1/security/cert-sign/sign-requests",
- ),
- apiClient.get(
- "/api/v1/security/cert-sign/sessions",
- ),
- ]);
-
- setSignRequests(requestsResponse.data);
- setMySessions(sessionsResponse.data);
- } catch (err) {
- const errorObj =
- err instanceof Error
- ? err
- : new Error("Failed to fetch signing data");
- setError(errorObj);
- console.error("Failed to fetch signing data:", err);
-
- if (!silent) {
- alert({
- alertType: "warning",
- title: t("common.error"),
- body: t("certSign.fetchFailed", "Failed to load signing data"),
- expandable: false,
- durationMs: 2500,
- });
- }
- } finally {
- if (!silent) setLoading(false);
- }
- },
- [enabled, t],
- );
-
- // Initial fetch
+ // isLoadingError is "failed with nothing cached", i.e. a first load. A poll
+ // that fails after a success keeps the old data and stays silent.
+ const reportedRef = useRef(false);
useEffect(() => {
- if (enabled) {
- fetchData();
- }
- }, [enabled, fetchData]);
-
- // Auto-refresh
- useEffect(() => {
- if (!enabled || !autoRefreshInterval || autoRefreshInterval <= 0) {
+ if (!isLoadingError) {
+ reportedRef.current = false;
return;
}
+ if (reportedRef.current) return;
+ reportedRef.current = true;
+ notifyFailure();
+ }, [isLoadingError, notifyFailure]);
- const interval = setInterval(() => {
- fetchData({ silent: true });
- }, autoRefreshInterval);
+ // Neither isLoading nor isFetching alone matches the old `silent` flag: a
+ // user-initiated refresh showed the spinner even with data on screen, a
+ // background poll never did. isFetching cannot tell them apart, so track it.
+ const [refreshing, setRefreshing] = useState(false);
- return () => clearInterval(interval);
- }, [enabled, autoRefreshInterval, fetchData]);
+ const explicitRefetch = useCallback(async () => {
+ setRefreshing(true);
+ try {
+ const result = await refetch();
+ // Reported here rather than by the effect: a user-initiated refresh
+ // should say so even when stale data is already on screen.
+ if (result.error && !reportedRef.current) notifyFailure();
+ } finally {
+ setRefreshing(false);
+ }
+ }, [refetch, notifyFailure]);
return {
- signRequests,
- mySessions,
- loading,
- error,
- refetch: fetchData,
+ signRequests: data?.signRequests ?? EMPTY_REQUESTS,
+ mySessions: data?.mySessions ?? EMPTY_SESSIONS,
+ loading: isLoading || refreshing,
+ error: (error as Error | null) ?? null,
+ refetch: explicitRefetch,
};
};
diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts
index d95f674011..3e44395de0 100644
--- a/frontend/editor/src/core/query/keys.ts
+++ b/frontend/editor/src/core/query/keys.ts
@@ -8,6 +8,7 @@ export const qk = {
["editor", "endpointEnabled", endpoint] as const,
footerInfo: () => ["editor", "footerInfo"] as const,
groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const,
+ signingSessions: () => ["editor", "signingSessions"] as const,
/** Keyed on the asking identity: two users must never share one answer. */
portalAccess: (userId: string | null) =>
["editor", "portalAccess", userId] as const,
From 993adaa3cd19391a208246b8320eb1dfd425832e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sat, 29 Aug 2026 14:50:09 +0000
Subject: [PATCH 18/27] build(deps-dev): bump @iconify-json/material-symbols
from 1.2.83 to 1.2.89 in /frontend in the iconify group across 1 directory
(#7641)
Bumps the iconify group with 1 update in the /frontend directory:
[@iconify-json/material-symbols](https://github.com/iconify/icon-sets).
Updates `@iconify-json/material-symbols` from 1.2.83 to 1.2.89
Commits
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
frontend/package-lock.json | 8 ++++----
frontend/package.json | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6ade3d2a28..b7c3dcc569 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -87,7 +87,7 @@
"web-vitals": "^5.1.0"
},
"devDependencies": {
- "@iconify-json/material-symbols": "^1.2.83",
+ "@iconify-json/material-symbols": "^1.2.89",
"@iconify/react": "^6.0.2",
"@iconify/utils": "^3.1.4",
"@playwright/test": "^1.55.0",
@@ -1982,9 +1982,9 @@
"license": "MIT"
},
"node_modules/@iconify-json/material-symbols": {
- "version": "1.2.83",
- "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.83.tgz",
- "integrity": "sha512-4I2rfNlaoyn4zIcdJDxUMuPV1pVp8Tgwy+eJvyAZuOpmPtOsniQ8Dug6wzRD5s9KLyQA3smFvuBphVdYG7NWQA==",
+ "version": "1.2.89",
+ "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.89.tgz",
+ "integrity": "sha512-wYJjKAOptNhc8Bq65PPJVEz/4Ozm+b3ZWEB3H65yGw37dr9AvXibW0GkEdVD7qrCtbD6aC6fY7SLmQCuUGtEAQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
diff --git a/frontend/package.json b/frontend/package.json
index 71a71661e7..10b0e960f9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -109,7 +109,7 @@
]
},
"devDependencies": {
- "@iconify-json/material-symbols": "^1.2.83",
+ "@iconify-json/material-symbols": "^1.2.89",
"@iconify/react": "^6.0.2",
"@iconify/utils": "^3.1.4",
"@playwright/test": "^1.55.0",
From 41cbd97b483c03f6910232fe32deae79ef226eb8 Mon Sep 17 00:00:00 2001
From: Ludy
Date: Sat, 29 Aug 2026 14:50:11 +0000
Subject: [PATCH 19/27] ci: reuse shared Python dependency cache across
workflows (#7693)
# Description of Changes
This PR removes workflow-specific cache suffixes from Python dependency
caching in several CI workflows.
Previously, the following workflows appended their own `cache-suffix`
even though they use the same Python dependency files:
- `ai-engine.yml`
- `check-generated-models.yml`
- `pre_commit.yml`
- `sync_files_v2.yml`
All of these workflows use the same cache dependency inputs:
- `engine/pyproject.toml`
- `engine/uv.lock`
The workflow-specific suffixes caused separate cache entries to be
created for effectively identical dependency sets. This resulted in
unnecessary cache duplication and reduced cache reuse between workflows.
By removing the suffixes, these workflows can now share the same cache
when their dependency inputs and other cache key components match.
This change reduces redundant cache storage, improves cache hit
potential across CI workflows, and avoids repeatedly creating equivalent
caches under different names.
No functional application behavior is changed. The modification only
affects CI cache key generation and reuse.
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.github/workflows/ai-engine.yml | 1 -
.github/workflows/check-generated-models.yml | 1 -
.github/workflows/pre_commit.yml | 1 -
.github/workflows/sync_files_v2.yml | 1 -
4 files changed, 4 deletions(-)
diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml
index caa5af1acb..357dfa1e49 100644
--- a/.github/workflows/ai-engine.yml
+++ b/.github/workflows/ai-engine.yml
@@ -34,7 +34,6 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- cache-suffix: ai-engine
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml
index 476f9d21a7..f917a4c602 100644
--- a/.github/workflows/check-generated-models.yml
+++ b/.github/workflows/check-generated-models.yml
@@ -42,7 +42,6 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- cache-suffix: generated-models
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml
index 3566f5864f..fbd9efc474 100644
--- a/.github/workflows/pre_commit.yml
+++ b/.github/workflows/pre_commit.yml
@@ -31,7 +31,6 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- cache-suffix: pre-commit
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml
index a199fd6cbd..f688476159 100644
--- a/.github/workflows/sync_files_v2.yml
+++ b/.github/workflows/sync_files_v2.yml
@@ -59,7 +59,6 @@ jobs:
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- cache-suffix: sync-files
- name: Install Python dependencies
run: |
From 3718af45ff36ecc1823a48fb7a7b5a7f7af90107 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sat, 29 Aug 2026 14:50:15 +0000
Subject: [PATCH 20/27] build(deps): bump the mui group across 1 directory with
2 updates (#7602)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the mui group with 1 update in the /frontend directory:
[@mui/icons-material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material).
Updates `@mui/icons-material` from 9.2.0 to 9.3.1
Release notes
#1237aa536e7
- Fix a gap at the top of the list after an end-anchored prepend in
directDomUpdates mode. The prepend grows the total size and
bumps scrollOffset to the new bottom in the same pass, but
the size container's height was written after_willUpdate synced the scroll position — so the browser
clamped the scrollTop write to the stale (shorter)
scrollHeight, leaving whitespace at the top until the next
scroll. The container is now grown before the scroll sync. Only affected
directDomUpdates mode (React-rendered sizers receive their
height during render).
#12012ba5eb6
- Make directDomUpdates a no-op for direct DOM writes when
containerRef is omitted. Previously the virtualizer still
wrote item positions while never sizing the container (a broken
half-state). Now omitting containerRef skips all direct
writes while still skipping re-renders, letting consumers own the DOM
updates themselves (e.g. in onChange).
#1237aa536e7
- Fix a gap at the top of the list after an end-anchored prepend in
directDomUpdates mode. The prepend grows the total size and
bumps scrollOffset to the new bottom in the same pass, but
the size container's height was written after_willUpdate synced the scroll position — so the browser
clamped the scrollTop write to the stale (shorter)
scrollHeight, leaving whitespace at the top until the next
scroll. The container is now grown before the scroll sync. Only affected
directDomUpdates mode (React-rendered sizers receive their
height during render).