Merge branch 'main' into move_pr_check

This commit is contained in:
Ludy
2026-08-18 20:27:47 +02:00
committed by GitHub
78 changed files with 2819 additions and 1000 deletions
@@ -191,22 +191,19 @@ jobs:
# untrusted tree gets built below - never leave credentials in .git/config
persist-credentials: false
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-deploy-pr-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+9 -12
View File
@@ -34,23 +34,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check Java formatting (Spotless)
@@ -109,7 +106,7 @@ jobs:
STIRLING_FLAVOR: ${{ matrix.flavor }}
# Configure the Gradle daemon explicitly; GRADLE_OPTS alone only
# configures the Gradle client JVM.
GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC'
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC"
- name: Check Test Reports Exist
if: always()
+24 -9
View File
@@ -15,6 +15,11 @@ name: Enterprise E2E (Playwright)
on:
workflow_call:
inputs:
use_shared_cache:
required: false
type: boolean
default: false
push:
branches: ["main"]
schedule:
@@ -56,21 +61,31 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Restore cache Gradle
if: inputs.use_shared_cache == false
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-playwright-e2e-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
+45 -14
View File
@@ -74,29 +74,48 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Calculate Gradle cache key
id: gradle-cache-key
shell: bash
run: |
echo "key=gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}" >> "$GITHUB_OUTPUT"
- name: Cache Gradle (lookup-only)
id: cache-gradle-restore
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: ${{ steps.gradle-cache-key.outputs.key }}
lookup-only: true
- name: Set up JDK 25
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Resolve backend dependencies
run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
run: ./gradlew :stirling-pdf:classes --no-daemon
env:
STIRLING_FLAVOR: saas
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Save cache Gradle User Home
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ steps.gradle-cache-key.outputs.key }}
build:
needs: [files-changed, gradle-cache-prime]
permissions:
@@ -178,6 +197,8 @@ jobs:
contents: read
uses: ./.github/workflows/build-enterprise.yml
secrets: inherit
with:
use_shared_cache: true
check-licence:
if: needs.files-changed.outputs.build == 'true'
@@ -201,7 +222,14 @@ jobs:
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime]
needs:
[
files-changed,
build,
check-generateOpenApiDocs,
check-licence,
gradle-cache-prime,
]
permissions:
contents: read
packages: read
@@ -213,7 +241,7 @@ jobs:
tauri-build:
if: needs.files-changed.outputs.tauri == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
pull-requests: write
@@ -227,6 +255,7 @@ jobs:
with:
platform: windows-macos
sign: true
use_shared_cache: true
ai-engine:
if: needs.files-changed.outputs.engine == 'true'
@@ -248,6 +277,8 @@ jobs:
contents: read
uses: ./.github/workflows/check-generated-models.yml
secrets: inherit
with:
use_shared_cache: true
pre-commit:
needs: [files-changed]
+21 -9
View File
@@ -9,6 +9,11 @@ name: Check generated models
# post-merge safety net.
on:
workflow_call:
inputs:
use_shared_cache:
required: false
type: boolean
default: false
push:
branches: [main]
@@ -38,22 +43,29 @@ jobs:
engine/uv.lock
cache-suffix: generated-models
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
java-version: "25"
distribution: "temurin"
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Cache Gradle User Home
- name: Restore cache Gradle
if: inputs.use_shared_cache == false
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-generated-models-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+8 -11
View File
@@ -21,23 +21,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses for compatibility
+8 -11
View File
@@ -22,23 +22,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate OpenAPI documentation
+8 -11
View File
@@ -40,23 +40,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
+8 -11
View File
@@ -25,23 +25,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: 25
distribution: temurin
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# Keep the normal formatting path here so this smoke test exercises the
# same Gradle configuration as the backend build.
- name: Build Stirling-PDF JAR
+8 -11
View File
@@ -33,23 +33,20 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
# container builder can't see that store, so skip it here and let
+10 -28
View File
@@ -21,39 +21,21 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# Gradle does not retry 429s, and a cold cache resolving the buildscript
# classpath is exactly where Maven Central rate-limits us. Retry it here,
# where a failure is cheap, instead of inside the backgrounded bootRun.
- name: Prime Gradle dependencies
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
run: |
for attempt in 1 2 3; do
if ./gradlew --quiet -PnoSpotless :stirling-pdf:classes; then
exit 0
fi
echo "::warning::Gradle dependency resolution failed (attempt $attempt of 3)"
sleep $((attempt * 30))
done
echo "::error::Gradle could not resolve dependencies after 3 attempts"
exit 1
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -287,22 +287,19 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-license-report-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+24 -33
View File
@@ -52,22 +52,19 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -145,22 +142,19 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -238,6 +232,14 @@ jobs:
toolchain: stable
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
# x86_64 JDK is set up first so the aarch64 step below can leave its
# JAVA_HOME as the active one. The macOS universal JRE build needs
# jmods from both arches; the x64 path is captured into the env
@@ -261,17 +263,6 @@ jobs:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+8 -11
View File
@@ -58,22 +58,19 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-push-docker-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Set up Docker Buildx
id: buildx
+8 -11
View File
@@ -36,22 +36,19 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: gradle-swagger-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+22 -11
View File
@@ -26,6 +26,10 @@ on:
required: false
type: boolean
default: false
use_shared_cache:
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
platform:
@@ -168,6 +172,24 @@ jobs:
# Save the dependency cache even if a later step fails
cache-on-failure: true
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Restore cache Gradle
if: inputs.use_shared_cache == false
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-build-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
@@ -187,17 +209,6 @@ jobs:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+8 -11
View File
@@ -79,23 +79,20 @@ jobs:
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build application
@@ -1879,6 +1879,9 @@ width = "Width"
[app]
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
[appBanner]
dismiss = "Dismiss"
[attachments]
convertToPdfA3b = "Convert to PDF/A-3b"
convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments"
@@ -4827,9 +4830,6 @@ title = "Image to PDF"
[imageToPdf]
tags = "conversion,img,jpg,picture,photo"
[infoBanner]
dismiss = "Dismiss"
[invite]
acceptError = "Failed to create account"
accountFor = "Creating account for"
@@ -5057,6 +5057,14 @@ title = "Upload from Mobile"
tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide"
title = "PDF Multi Tool"
[navFooter]
openEditor = "Open PDF Editor"
openProcessor = "Open PDF Processor"
[navFooter.credits]
count = "{{remaining}} of {{total}}"
label = "Free credits"
[oauth.error]
message = "Authentication was not successful. You can close this window and try again."
title = "Authentication Failed"
@@ -5621,8 +5629,8 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man
freeTitle = "Unlimited PDF editing"
[payg.free.hero]
barAria = "Free PDFs used"
capSuffix = "/ {{limit}} free PDFs"
barAria = "Free PDFs remaining"
capSuffix = "of {{limit}} free PDFs left"
metaCategories = "Automation · AI · API requests"
[payg.free.member]
@@ -6685,12 +6693,12 @@ reachedTitle = "Monthly spend limit reached"
title = "Couldn't open Stripe portal"
[portal.billing.walletMeter]
barAria = "Free PDFs used"
capSuffix_one = "of {{allowance}} free PDFs used"
capSuffix_other = "of {{allowance}} free PDFs used"
barAria = "Free PDFs remaining"
capSuffix_one = "of {{allowance}} free PDF left"
capSuffix_other = "of {{allowance}} free PDFs left"
eyebrow = "Processor trial"
statusLabel_one = "{{remaining}} left"
statusLabel_other = "{{remaining}} left"
statusLabel_one = "{{used}} used"
statusLabel_other = "{{used}} used"
sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
title_one = "Process {{allowance}} PDFs free"
title_other = "Process {{allowance}} PDFs free"
@@ -7665,7 +7673,6 @@ integrations = "Integrations"
pipelines = "Pipelines"
policies = "Policies"
procurement = "Procurement"
settings = "Settings"
sources = "Sources"
usage = "Usage & Billing"
users = "Users"
@@ -642,7 +642,6 @@ const CODE_EXEMPT_PATH = [
/mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons|BrandMarks/,
/\/onboarding\//,
/addStamp|addWatermark|\/tooltips\//,
/UpgradeBanner|AdminPlanSection/,
// Stories are checked like app code; colour-as-data lines opt out with
// `theme-allow-color`.
/\.test\.[jt]sx?$|\/types\//,
@@ -3,7 +3,7 @@ import { Group, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
/**
@@ -105,7 +105,7 @@ export function TeamInvitationBanner() {
);
return (
<InfoBanner
<AppBanner
icon="mail"
message={
<Group
@@ -120,10 +120,6 @@ export function TeamInvitationBanner() {
}
show={shouldShow}
dismissible={false}
background="var(--mantine-color-dark-7)"
borderColor="var(--mantine-color-dark-5)"
textColor="rgba(255, 255, 255, 0.95)"
iconColor="rgba(255, 255, 255, 0.95)"
/>
);
}
@@ -12,6 +12,7 @@ import {
formatPeriodDate,
MeterBar,
meterState,
remainingMeter,
} from "@app/billing";
import "@app/components/shared/config/configSections/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.css";
@@ -48,7 +49,8 @@ export function useFreeSnapshot(): FreeSnapshot {
export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
const { t } = useTranslation();
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
const remaining = Math.max(0, snap.billableLimit - snap.billableUsed);
const { state, pct } = remainingMeter(remaining, snap.billableLimit);
const stateLabel =
state === "DEGRADED"
? t("payg.free.state.limitReached", "Limit reached")
@@ -60,9 +62,9 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
<MeterBar
state={state}
pct={pct}
barLabel={t("payg.free.hero.barAria", "Free PDFs used")}
figure={snap.billableUsed.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
barLabel={t("payg.free.hero.barAria", "Free PDFs remaining")}
figure={remaining.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "of {{limit}} free PDFs left", {
limit: snap.billableLimit.toLocaleString(),
})}
statusLabel={stateLabel}
@@ -175,15 +177,15 @@ export function prepaidSnapshotFromWallet(
}
/**
* Prepaid capacity meter. The bar fills as the pool is drawn down ({@code used =
* total remaining}), so it WARNs when the pool is running low and DEGRADEs once
* exhausted — same bands as the free/cap meters. Prepaid is consumed ahead of the
* meter and outside the spend cap, so it reads as its own dimension.
* Prepaid capacity meter. The bar shows what is left and drains towards empty,
* while the bands still key on what is gone ({@code used = total remaining}), so
* it WARNs when the pool is running low and DEGRADEs once exhausted — same bands
* as the free/cap meters. Prepaid is consumed ahead of the meter and outside the
* spend cap, so it reads as its own dimension.
*/
export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
const { t } = useTranslation();
const used = Math.max(0, snap.total - snap.remaining);
const { state, pct } = meterState(used, snap.total);
const { state, pct } = remainingMeter(snap.remaining, snap.total);
const stateLabel =
state === "DEGRADED"
? t("payg.prepaid.state.exhausted", "Used up")
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { useWallet } from "@app/hooks/useWallet";
import {
readCachedCredits,
writeCachedCredits,
type CachedCredits,
} from "@app/services/navFooterCache";
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
/** The wallet reduced to what the footer shows: figures, or null for a payer. */
function toCredits(
status: string,
freeRemaining: number,
freeAllowance: number,
): CachedCredits {
// Free teams only. The grant is a lifetime pool that survives subscribing, so
// a paying team would otherwise sit on a permanent "0 of 500" in red while
// nothing is wrong. Plan draws the same line — subscribed teams get the
// spend-vs-cap meter there, and admins get usage in the processor.
if (status === "subscribed") return null;
return { remaining: freeRemaining, total: freeAllowance };
}
/**
* Cloud builds read the free grant off the live wallet — the same snapshot the
* Plan page's free meter renders, so the sidebar and Plan can't disagree.
*
* Seeded from the last figures this browser saw, so the row is right at first
* paint and stays put while the wallet refetches underneath. Only a browser
* that has never loaded a wallet has nothing to show, and that one time the row
* animates in. The seed is read once, at first render: later reads would fight
* the live value, and the whole point is that the row stops moving.
*/
export function useFreeCreditsSummary(): NavFooterCredits | null {
const { wallet } = useWallet();
const [seed] = useState(readCachedCredits);
const live = wallet
? toCredits(wallet.status, wallet.freeRemaining, wallet.freeAllowance)
: undefined;
// useWallet reuses the snapshot reference when nothing changed, so keying on
// it writes only on a real change, not on every render.
useEffect(() => {
if (live !== undefined) writeCachedCredits(live);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet]);
return (live !== undefined ? live : seed) ?? null;
}
@@ -0,0 +1,13 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
/**
* Cloud editor builds open the settings modal on its Plan section, which is
* where the free grant is explained and the Processor plan is switched on.
* Routed rather than called directly because the modal is URL-driven here
* (`/settings/*`), the same path the admin tour uses to open it.
*/
export function useOpenPlan(): (() => void) | null {
const navigate = useNavigate();
return useCallback(() => navigate("/settings/plan"), [navigate]);
}
+86 -4
View File
@@ -32,6 +32,14 @@
* promise see the UI flip exactly once the new state is visible — no
* intermediate flash of the old value.
*
* <h2>Freshness</h2>
*
* The figures drain as metered work runs, so a mounted consumer re-reads the
* wallet every {@link WALLET_POLL_MS} and again whenever the tab regains
* visibility. Those refreshes are silent — they leave {@code loading} and
* {@code error} alone and only commit fresher data — so consumers that gate on
* those flags don't flicker on a background tick.
*
* <h2>Dev preview fallback</h2>
*
* When the hook is rendered outside the saas app (e.g. on {@code
@@ -178,6 +186,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
return prev;
}
/**
* How often a mounted consumer re-reads the wallet. Matches the app query
* client's staleTime, so the sidebar meter and anything cached elsewhere age
* out on the same clock.
*/
const WALLET_POLL_MS = 30_000;
export function useWallet(): UseWalletResult {
// Resolved once: the dev-preview side-channel when rendered outside the real
// app (saas /dev/payg-preview route), else null (every real build + desktop).
@@ -201,13 +216,29 @@ export function useWallet(): UseWalletResult {
// "the request fired." Cleared when no load is pending.
const inFlight = useRef<Promise<void> | null>(null);
// Set for refreshes the user didn't ask for (the poll below). Silence governs
// whether a load may RAISE `loading` / `error`, never whether it may clear
// them: consumers gate on both — the limit modals do
// `if (loading || !wallet) return null`, and Plan swaps in an error alert —
// so a background tick must not blink an open modal out or replace a working
// page over a transient failure. Clearing is always the latest request's job,
// silent or not; a silent load that skipped the clear would strand `loading`
// true after superseding a visible one, which suppresses those modals for the
// rest of the session.
const silentRefresh = useRef(false);
useEffect(() => {
const reqId = ++latestReqId.current;
let cancelled = false;
const silent = silentRefresh.current;
silentRefresh.current = false;
const promise = (async () => {
setLoading(true);
setError(null);
if (!silent) {
setLoading(true);
setError(null);
}
if (devPreview) {
const synth = devPreview.buildWallet(devPreview.role());
@@ -221,11 +252,22 @@ export function useWallet(): UseWalletResult {
const res = await apiClient.get<Wallet>("/api/v1/payg/wallet");
if (cancelled || reqId !== latestReqId.current) return;
setWallet((prev) => reuseIfEqual(prev, res.data));
// Fresh data retires any earlier failure, including one a silent poll
// is recovering from — otherwise Plan keeps its alert over good data.
setError(null);
} catch (e: unknown) {
if (cancelled || reqId !== latestReqId.current) return;
console.warn("[useWallet] fetch failed", e);
setError(e instanceof Error ? e.message : "Failed to load wallet");
if (!silent) {
console.warn("[useWallet] fetch failed", e);
setError(e instanceof Error ? e.message : "Failed to load wallet");
}
// A failed background refresh is a non-event: the last good snapshot
// stands and the next tick self-heals, so it neither surfaces nor
// logs — otherwise an offline tab warns every WALLET_POLL_MS.
} finally {
// Deliberately not gated on `silent`: whichever load is latest owns
// settling the flag, or a silent refresh that supersedes a visible one
// leaves it stuck true.
if (!cancelled && reqId === latestReqId.current) {
setLoading(false);
}
@@ -242,6 +284,46 @@ export function useWallet(): UseWalletResult {
};
}, [devPreview, refetchTick]);
// The wallet drains as automation, AI and API work runs, so a figure fetched
// on mount goes stale while the user watches it. Refresh on a timer, and
// immediately on returning to the tab — coming back to a stale number is the
// case people actually notice. Hidden tabs don't poll, and the dev-preview
// wallet is synthesised locally so there is nothing to re-read.
useEffect(() => {
if (devPreview) return;
let timer: ReturnType<typeof setInterval> | undefined;
const refresh = () => {
silentRefresh.current = true;
setRefetchTick((t) => t + 1);
};
const stop = () => {
if (timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
};
const start = () => {
stop();
timer = setInterval(refresh, WALLET_POLL_MS);
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
refresh();
start();
} else {
stop();
}
};
if (document.visibilityState === "visible") start();
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [devPreview]);
const refetch = useCallback(async () => {
setRefetchTick((t) => t + 1);
// Snapshot the next-tick promise so the caller awaits this refetch
@@ -4,7 +4,7 @@ import { AppLayout } from "@app/components/AppLayout";
import { BannerProvider, useBanner } from "@app/contexts/BannerContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
const meta = {
title: "Components/AppLayout",
@@ -49,7 +49,7 @@ function BannerSetter() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(
<InfoBanner
<AppBanner
icon="info-rounded"
title="Heads up"
message="This workspace is running in offline mode."
@@ -0,0 +1,115 @@
/* App-wide top bar. One shape, four tones — callers pick a tone, never a colour.
Named `app-banner`, not `sui-banner`: that belongs to the SUI Banner primitive. */
.app-banner {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 3.5rem;
padding: 0.75rem 1rem;
/* Full-bleed across the top of the app: square corners, one rule underneath. */
border-bottom: 1px solid var(--app-banner-border);
background: var(--app-banner-bg);
color: var(--c-text);
}
.app-banner--compact {
min-height: 2.75rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
}
.app-banner--info {
--app-banner-bg: var(--c-primary-subtle);
--app-banner-border: var(--c-primary-border);
--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--warning {
--app-banner-bg: var(--c-warning-subtle);
--app-banner-border: color-mix(in srgb, var(--c-warning) 32%, transparent);
--app-banner-icon: var(--c-warning);
}
.app-banner--danger {
--app-banner-bg: var(--c-danger-subtle);
--app-banner-border: color-mix(in srgb, var(--c-danger) 32%, transparent);
--app-banner-icon: var(--c-danger);
}
/* Only the icon carries the tone; text stays neutral in every tone. */
.app-banner__icon {
display: flex;
flex-shrink: 0;
align-items: center;
color: var(--app-banner-icon);
}
.app-banner__body {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.125rem;
}
.app-banner__title {
font-size: 0.875rem;
font-weight: 600;
}
.app-banner__message {
font-size: 0.8125rem;
line-height: 1.4;
color: var(--c-text-muted);
}
.app-banner__body:not(:has(.app-banner__title)) .app-banner__message {
color: inherit;
font-weight: 500;
}
.app-banner--compact .app-banner__title,
.app-banner--compact .app-banner__message {
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);
}
/* 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__actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
}
/* Mantine trims the leading padding when a button has a left section, which reads
as off-centre next to the label. Even it back up. */
.app-banner__actions .sui-btn .mantine-Button-inner {
padding-inline: 0;
}
.app-banner__actions .sui-btn {
padding-inline: 0.875rem;
}
@@ -0,0 +1,194 @@
import type { ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AppBanner } from "@app/components/shared/AppBanner";
const meta = {
title: "Shared/AppBanner",
component: AppBanner,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AppBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Info: Story = {
args: {
icon: "info-rounded",
title: "Heads up",
message: "This document contains form fields that will be flattened.",
},
};
export const Promo: Story = {
args: {
tone: "promo",
icon: "stars-rounded",
title: "Upgrade to Server Plan",
message:
"Get the most out of Stirling PDF with unlimited users and advanced features.",
buttonText: "Upgrade Now",
buttonIcon: "upgrade-rounded",
onButtonClick: () => {},
compact: true,
},
};
export const Warning: Story = {
args: {
tone: "warning",
icon: "warning-rounded",
title: "Action required",
message: "Some pages could not be processed and were skipped.",
buttonText: "Review",
onButtonClick: () => {},
},
};
export const Danger: Story = {
args: {
tone: "danger",
icon: "warning-rounded",
title: "This server needs admin attention",
message: "Review the license requirements to keep this server compliant.",
buttonText: "See info",
buttonIcon: "info-rounded",
onButtonClick: () => {},
dismissible: false,
},
};
export const Compact: Story = {
args: {
compact: true,
icon: "info-rounded",
message: "Autosave is enabled for this file.",
dismissible: false,
},
};
/** Message-only, no title: the message takes the title's weight so the bar still reads. */
export const MessageOnly: Story = {
args: {
icon: "picture-as-pdf-rounded",
message:
"Make Stirling PDF your default application for opening PDF files.",
buttonText: "Set Default",
onButtonClick: () => {},
secondaryButtonText: "Don't remind me again",
onSecondaryButtonClick: () => {},
},
};
function Row({ caption, children }: { caption: string; children: ReactNode }) {
return (
<section
style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}
>
<span
style={{
fontSize: "0.6875rem",
fontWeight: 600,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--c-text-subtle)",
padding: "0 1rem",
}}
>
{caption}
</span>
{children}
</section>
);
}
/**
* Every top bar the app can show, in one place: each entry mirrors a real caller,
* so a change to the component is visible against the whole set at once. Renders a
* composition rather than the component, so it takes no args of its own.
*/
export const AllTopBars: StoryObj = {
render: () => (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
padding: "1.5rem 0",
background: "var(--c-bg)",
}}
>
<Row caption="Upgrade prompt · UpgradeBanner (friendly)">
<AppBanner
tone="promo"
compact
icon="stars-rounded"
title="Upgrade to Server Plan"
message="Get the most out of Stirling PDF with unlimited users and advanced features."
buttonText="Upgrade Now"
buttonIcon="upgrade-rounded"
onButtonClick={() => {}}
/>
</Row>
<Row caption="Server needs attention · UpgradeBanner (urgent)">
<AppBanner
tone="warning"
icon="warning-rounded"
title="This server needs admin attention"
message="Review the license requirements to keep this server compliant."
buttonText="See info"
buttonIcon="info-rounded"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Free tier limit reached · AdminPlanSection">
<AppBanner
tone="warning"
icon="warning-rounded"
title="Free self-hosted limit reached"
message="You have 12 users on a plan that covers 10."
buttonText="See plans"
buttonIcon="upgrade-rounded"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Team invitation · TeamInvitationBanner">
<AppBanner
icon="mail"
message="You have been invited to join the Acme Legal team."
buttonText="Accept"
onButtonClick={() => {}}
secondaryButtonText="Decline"
onSecondaryButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Set as default app · DefaultAppBanner (desktop)">
<AppBanner
icon="picture-as-pdf-rounded"
message="Make Stirling PDF your default application for opening PDF files."
buttonText="Set Default"
onButtonClick={() => {}}
secondaryButtonText="Don't remind me again"
onSecondaryButtonClick={() => {}}
/>
</Row>
<Row caption="Danger tone (available, no caller yet)">
<AppBanner
tone="danger"
icon="warning-rounded"
title="Storage is full"
message="New uploads will fail until space is freed."
buttonText="Manage storage"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
</div>
),
};
@@ -0,0 +1,124 @@
import React, { ReactNode } from "react";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import "@app/components/shared/AppBanner.css";
/** Picks the whole look. Callers choose meaning, never colours. */
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" },
warning: { variant: "primary", accent: "warning" },
danger: { variant: "primary", accent: "danger" },
} as const;
interface AppBannerProps {
/** A LocalIcon name, or a pre-rendered node (e.g. a logo) dropped in as-is. */
icon?: string | ReactNode;
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick?: () => void;
/** Muted secondary action, e.g. "Don't remind me again". */
secondaryButtonText?: string;
onSecondaryButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: AppBannerTone;
compact?: boolean;
}
/** The app's top bar: dismissible messaging above the workspace. */
export const AppBanner: React.FC<AppBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = "check-circle-rounded",
onButtonClick,
secondaryButtonText,
onSecondaryButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = "info",
compact = false,
}) => {
const { t } = useTranslation();
if (!show) return null;
const iconSize = compact ? "1rem" : "1.25rem";
return (
<div
className={[
"app-banner",
`app-banner--${tone}`,
compact ? "app-banner--compact" : "",
]
.filter(Boolean)
.join(" ")}
>
{icon != null && (
<span className="app-banner__icon" aria-hidden>
{typeof icon === "string" ? (
<LocalIcon icon={icon} width={iconSize} height={iconSize} />
) : (
icon
)}
</span>
)}
<div className="app-banner__body">
{title && <span className="app-banner__title">{title}</span>}
<span className="app-banner__message">{message}</span>
</div>
<div className="app-banner__actions">
{buttonText && onButtonClick && (
<Button
variant={TONE_BUTTON[tone].variant}
accent={TONE_BUTTON[tone].accent}
size="sm"
loading={loading}
onClick={onButtonClick}
leftSection={
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
}
>
{buttonText}
</Button>
)}
{secondaryButtonText && onSecondaryButtonClick && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onSecondaryButtonClick}
>
{secondaryButtonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={() => onDismiss?.()}
aria-label={t("appBanner.dismiss", "Dismiss")}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
)}
</div>
</div>
);
};
@@ -48,9 +48,55 @@
transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428);
}
/* One-shot "thinking" drift — the two parallelograms swap past each other and
settle back. Same motion the chat FAB loops while the agent works, but this
pair starts and ends at rest (translate 0, full opacity) so a single
iteration can end without snapping. Callers apply it for one beat; see
NavFooter.css for the hover use. */
@keyframes sui-brandmark-drift-a {
0%,
100% {
transform: translate(0, 0);
opacity: 1;
}
25% {
transform: translate(-1px, -5px);
opacity: 0.55;
}
50% {
transform: translate(-6px, 0);
opacity: 0.9;
}
75% {
transform: translate(-1px, 5px);
opacity: 0.6;
}
}
@keyframes sui-brandmark-drift-b {
0%,
100% {
transform: translate(0, 0);
opacity: 1;
}
25% {
transform: translate(1px, 5px);
opacity: 0.85;
}
50% {
transform: translate(6px, 0);
opacity: 0.5;
}
75% {
transform: translate(1px, -5px);
opacity: 0.85;
}
}
@media (prefers-reduced-motion: reduce) {
.sui-brandmark__a,
.sui-brandmark__b {
transition: none;
animation: none;
}
}
@@ -75,16 +75,13 @@
padding: 0.25rem 0;
overflow: hidden;
}
.file-sidebar-footer-box {
padding: 0.25rem 0;
flex-shrink: 0;
}
/* The footer is the shared <NavFooter>: it brings its own boxes and padding,
so this class only positions it in the column. */
/* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and
let the boxes stack at the top — controls, then the settings footer right
after — instead of the files box stretching to fill. */
.file-sidebar[data-collapsed="true"] .file-sidebar-controls,
.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box {
.file-sidebar[data-collapsed="true"] .file-sidebar-controls {
padding: 0.25rem;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-files-box {
@@ -538,86 +535,3 @@
pointer-events: none;
animation: none;
}
/* ---- Bottom bar (user + settings) ---- */
.file-sidebar-bottom-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
flex-shrink: 0;
min-height: 40px;
}
/* Bottom bar settings icon tracks the right edge during collapse animation */
.file-sidebar-bottom-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--c-accent-text);
color: var(--c-text-on-primary);
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
user-select: none;
overflow: hidden;
}
/* No colored disc behind an actual photo; keep it for the initials fallback. */
.file-sidebar-bottom-avatar--picture {
background-color: transparent;
}
.file-sidebar-bottom-avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.file-sidebar-bottom-name {
flex: 1;
font-size: 13px;
font-weight: 500;
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.file-sidebar-bottom-bar[role="button"]:hover {
background-color: var(--c-hover);
}
.file-sidebar-bottom-bar[role="button"]:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
}
.file-sidebar-bottom-settings {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--c-text-subtle);
padding: 0;
flex-shrink: 0;
margin-left: auto;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings {
width: 32px;
height: 32px;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar {
justify-content: center;
padding: 8px 0;
}
@@ -22,13 +22,15 @@ import {
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useAuth } from "@app/auth/UseSession";
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
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 {
useIndexedDB,
useIndexedDBRevision,
} from "@app/contexts/IndexedDBContext";
import { accountService } from "@app/services/accountService";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
@@ -37,8 +39,7 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import AddIcon from "@mui/icons-material/Add";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import SettingsIcon from "@mui/icons-material/Settings";
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
import type { FileId } from "@app/types/file";
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
import { useLabelName } from "@app/data/labelDisplay";
@@ -241,43 +242,11 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const { addFiles } = useFileHandler();
const indexedDB = useIndexedDB();
// Each auth layer derives its own displayName from its native user shape.
// Fall back to the proprietary REST endpoint only when the auth
// context yields nothing - then to "User" as a generic last resort.
const { displayName: authDisplayName, isAnonymous } = useAuth();
const [accountUsername, setAccountUsername] = useState<string | null>(null);
const displayName =
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User");
const profilePictureUrl = useProfilePictureUrl();
const [pictureFailed, setPictureFailed] = useState(false);
useEffect(() => setPictureFailed(false), [profilePictureUrl]);
const showProfilePicture = !!profilePictureUrl && !pictureFailed;
useEffect(() => {
if (!config?.enableLogin) {
setAccountUsername(null);
return;
}
if (authDisplayName) {
// The auth context has a name; don't bother hitting the REST
// endpoint, but clear any stale cached value from a prior call.
setAccountUsername(null);
return;
}
accountService
.getAccountData()
.then((data) => {
// Always reflect the latest result - including clearing it on
// sign-out, when the endpoint returns no username (or 401s into
// the catch branch below). Without this, signing out would leave
// the old username on screen.
setAccountUsername(data?.username ?? null);
})
.catch(() => {
setAccountUsername(null);
});
}, [config?.enableLogin, authDisplayName]);
const { displayName, profilePictureUrl, isAnonymous } =
useAccountIdentity();
const credits = useFreeCreditsSummary();
const otherApp = useOtherAppSwitch();
const openPlan = useOpenPlan();
// Leaf files = user-visible files (excludes intermediate tool outputs)
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
@@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
)}
data-testid="open-files-page"
>
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
<OpenInFullIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<ActionIcon
variant="quiet"
@@ -1264,70 +1233,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
{/* Getting-started checklist, floating above the footer (SaaS only). */}
<SidebarChecklistSlot collapsed={collapsed} />
{/* Box 3 — account footer (avatar + name + settings). */}
<NavSurface className="file-sidebar-footer-box">
{/* Bottom bar: user name + settings */}
<Tooltip
label={
onOpenSettings
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
: displayName
}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-bottom-bar"
onClick={onOpenSettings}
role={onOpenSettings ? "button" : undefined}
tabIndex={onOpenSettings ? 0 : undefined}
onKeyDown={
onOpenSettings
? (e) => e.key === "Enter" && onOpenSettings()
: undefined
}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: displayName
}
style={onOpenSettings ? { cursor: "pointer" } : undefined}
>
<div
className={`file-sidebar-bottom-avatar${
showProfilePicture
? " file-sidebar-bottom-avatar--picture"
: ""
}`}
aria-label={displayName}
>
{showProfilePicture ? (
<img
src={profilePictureUrl}
alt=""
className="file-sidebar-bottom-avatar-img"
onError={() => setPictureFailed(true)}
/>
) : (
displayName.charAt(0).toUpperCase()
)}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<div className="file-sidebar-bottom-settings">
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</div>
)}
</div>
</Tooltip>
</NavSurface>
{/* Box 3 — the shared footer: credits, app switch, account row. */}
<NavFooter
className="file-sidebar-footer-box"
displayName={displayName}
profilePictureUrl={profilePictureUrl}
onOpenSettings={onOpenSettings}
credits={credits}
onOpenPlan={openPlan ?? undefined}
otherApp={otherApp}
collapsed={collapsed}
/>
</div>
);
},
@@ -1,38 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { InfoBanner } from "@app/components/shared/InfoBanner";
const meta = {
title: "Shared/InfoBanner",
component: InfoBanner,
parameters: { layout: "padded" },
} satisfies Meta<typeof InfoBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
icon: "info-rounded",
title: "Heads up",
message: "This document contains form fields that will be flattened.",
},
};
export const Warning: Story = {
args: {
tone: "warning",
icon: "warning-rounded",
title: "Action required",
message: "Some pages could not be processed and were skipped.",
buttonText: "Review",
onButtonClick: () => {},
},
};
export const Compact: Story = {
args: {
compact: true,
icon: "info-rounded",
message: "Autosave is enabled for this file.",
dismissible: false,
},
};
@@ -1,263 +0,0 @@
import React, { ReactNode } from "react";
import { Paper, Group, Text, Stack } from "@mantine/core";
import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
type InfoBannerTone = "info" | "warning";
const toneStyles: Record<
InfoBannerTone,
{
background: string;
border: string;
text: string;
icon: string;
buttonColor: string;
}
> = {
info: {
background: "var(--mantine-color-blue-0)",
border: "var(--mantine-color-blue-2)",
text: "var(--mantine-color-blue-9)",
icon: "var(--mantine-color-blue-6)",
buttonColor: "blue",
},
warning: {
background: "var(--mantine-color-orange-0)",
border: "var(--mantine-color-orange-3)",
text: "var(--color-amber-dark)",
icon: "var(--mantine-color-orange-7)",
buttonColor: "orange",
},
};
function toSharedButtonVariant(
variant: "light" | "filled" | "white" | "outline" | "subtle",
): ButtonVariant {
switch (variant) {
case "filled":
return "primary";
case "outline":
return "secondary";
case "subtle":
return "tertiary";
case "light":
case "white":
default:
return "secondary";
}
}
function toSharedButtonAccent(color: string | undefined): ButtonAccent {
// Mantine colours may carry a shade suffix (e.g. "orange.7"); use the hue.
const hue = (color ?? "").split(".")[0];
switch (hue) {
case "red":
return "danger";
case "green":
return "success";
case "yellow":
case "orange":
return "warning";
case "blue":
default:
return "default";
}
}
interface InfoBannerProps {
/**
* Either a LocalIcon name (string) for the standard sized icon slot, or a
* pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is.
*/
icon?: string | ReactNode;
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick?: () => void;
/** Optional muted secondary action (e.g. "Don't remind me again"). */
secondaryButtonText?: string;
onSecondaryButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: InfoBannerTone;
background?: string;
borderColor?: string;
textColor?: string;
iconColor?: string;
buttonColor?: string;
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
/** Override the button label colour (for dark/custom theme variants). */
buttonTextColor?: string;
minHeight?: number | string;
closeIconColor?: string;
compact?: boolean;
}
/**
* Generic info banner component for displaying dismissible messages at the top of the app
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = "check-circle-rounded",
onButtonClick,
secondaryButtonText,
onSecondaryButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = "info",
background,
borderColor,
textColor,
iconColor,
buttonColor,
buttonVariant = "light",
buttonTextColor,
minHeight = 56,
closeIconColor,
compact = false,
}) => {
const { t } = useTranslation();
if (!show) {
return null;
}
const toneStyle = toneStyles[tone] ?? toneStyles.info;
const resolvedTextColor = textColor ?? toneStyle.text;
const handleDismiss = () => {
onDismiss?.();
};
const iconSize = compact ? "1rem" : "1.2rem";
const textSize = compact ? "xs" : "sm";
return (
<Paper
p={compact ? "xs" : "sm"}
radius={0}
style={{
background: background ?? toneStyle.background,
border: "none",
borderBottom:
borderColor === "transparent"
? "none"
: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: "flex",
alignItems: "center",
}}
>
<Group
gap="sm"
align="center"
wrap="nowrap"
justify="space-between"
style={{ width: "100%" }}
>
<Group
gap={compact ? "xs" : "sm"}
align="center"
wrap="nowrap"
style={{ flex: 1, minWidth: 0 }}
>
{icon != null &&
(typeof icon === "string" ? (
<LocalIcon
icon={icon}
width={iconSize}
height={iconSize}
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
) : (
<div
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
>
{icon}
</div>
))}
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
fw={600}
size={textSize}
style={{ color: resolvedTextColor }}
>
{title}
</Text>
)}
<Text
fw={title ? 400 : 500}
size={textSize}
style={{ color: resolvedTextColor }}
lineClamp={compact ? 1 : 2}
>
{message}
</Text>
</Stack>
</Group>
<Group gap="xs" align="center" wrap="nowrap">
{buttonText && onButtonClick && (
<Button
variant={toSharedButtonVariant(buttonVariant)}
accent={toSharedButtonAccent(
buttonColor ?? toneStyle.buttonColor,
)}
size="sm"
loading={loading}
onClick={onButtonClick}
leftSection={
<LocalIcon
icon={buttonIcon}
width={compact ? "0.75rem" : "0.9rem"}
height={compact ? "0.75rem" : "0.9rem"}
/>
}
style={buttonTextColor ? { color: buttonTextColor } : undefined}
>
{buttonText}
</Button>
)}
{secondaryButtonText && onSecondaryButtonClick && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onSecondaryButtonClick}
style={{ color: "var(--c-text-muted)" }}
>
{secondaryButtonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={handleDismiss}
aria-label={t("infoBanner.dismiss", "Dismiss")}
style={{
color: closeIconColor ?? "var(--c-text-muted)",
}}
>
<LocalIcon
icon="close-rounded"
width={compact ? "0.85rem" : "1rem"}
height={compact ? "0.85rem" : "1rem"}
/>
</ActionIcon>
)}
</Group>
</Group>
</Paper>
);
};
@@ -0,0 +1,156 @@
/* Shared sidebar footer: one surface holding the link-account CTA, the credits
meter, the other-app switch and the account row, hairline-separated.
Structural only — every colour comes from a --c-* semantic token. */
.nav-footer {
display: flex;
flex-direction: column;
flex-shrink: 0;
/* Vertical only: the slots carry the horizontal padding so their separator
runs the full width of the surface. */
padding: 0.25rem 0;
overflow: hidden;
}
.nav-footer__slot {
padding-inline: 0.375rem;
}
/* Separators are drawn by the slots themselves, never as their own elements.
A slot whose contents render nothing (the link-account CTA returns null once
the org is linked, and an element is truthy even when it renders null) is
:empty, so it is skipped by both rules below — it can't leave a line behind,
and it can't push one to the top or bottom of the surface. A rule that only
ever matches a slot PRECEDED by another visible slot cannot draw a leading
separator, whatever the caller passes in. */
.nav-footer__slot:empty {
display: none;
}
.nav-footer__slot:not(:empty) ~ .nav-footer__slot:not(:empty) {
border-top: 1px solid var(--c-border-subtle);
margin-top: 0.25rem;
padding-top: 0.25rem;
}
/* Fades the rows up on the first footer mount of a page session only. They are
seeded from cache, so they're already present at first paint; replaying this
on every later mount (switching apps, remounting a view) would animate
content that never changed and read as a twitch. */
@keyframes nav-footer-row-in {
from {
opacity: 0;
transform: translateY(0.25rem);
}
to {
opacity: 1;
transform: none;
}
}
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
animation: nav-footer-row-in var(--motion-enter) both;
}
@media (prefers-reduced-motion: reduce) {
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
animation: none;
}
}
/* ---- Rows (link-account, credits, switch, account) ---- */
.nav-footer__row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 2.25rem;
padding: 0.25rem 0.375rem;
border: 0;
border-radius: 0.5rem;
background: none;
color: var(--c-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.nav-footer__row:disabled {
cursor: default;
}
.nav-footer__row:not(:disabled):hover {
background-color: var(--c-hover);
}
.nav-footer__row:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
}
.nav-footer__row-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.625rem;
}
/* Hovering the switch row plays the mark's "thinking" drift once — the same
motion the chat FAB loops, for a single beat, as a hint that the row hands
off to the other app. One iteration only: it starts and ends at rest, so
nothing snaps when it finishes, and re-entering the row replays it. */
.nav-footer__row:hover .sui-brandmark__a {
animation: sui-brandmark-drift-a 1.1s ease-in-out 1;
}
.nav-footer__row:hover .sui-brandmark__b {
animation: sui-brandmark-drift-b 1.1s ease-in-out 1;
}
.nav-footer__row-label {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Trailing affordance on a row: the account row's gear, the switch row's
leaving-this-app arrow. */
.nav-footer__trailing {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
color: var(--c-text-subtle);
}
/* Rows contributed by a caller (the link-account NavItem) sit in the same
surface, so match this footer's row metrics rather than the nav rail's. */
.nav-footer .sui-navitem {
min-height: 2.25rem;
padding: 0.25rem 0.375rem;
margin: 0;
border-radius: 0.5rem;
font-size: 0.8125rem;
}
/* ---- Collapsed icon rail ---- */
.nav-footer[data-collapsed] .nav-footer__slot {
padding-inline: 0.25rem;
}
.nav-footer[data-collapsed] .nav-footer__row {
justify-content: center;
padding-inline: 0;
}
.nav-footer[data-collapsed] .sui-navitem {
justify-content: center;
padding-inline: 0;
}
@@ -0,0 +1,119 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import LinkIcon from "@mui/icons-material/Link";
import { NavItem } from "@app/ui/NavItem";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
/** Stands in for a CTA that has decided it has nothing to show. */
function RendersNothing() {
return null;
}
const meta: Meta<typeof NavFooter> = {
title: "Shared/NavFooter",
component: NavFooter,
parameters: { layout: "padded" },
args: {
displayName: "admin",
onOpenSettings: () => {},
credits: { remaining: 247, total: 500 },
onOpenPlan: () => {},
otherApp: { app: "processor", onOpen: () => {} },
},
decorators: [
(S) => (
<div
style={{
width: "16.25rem",
background: "var(--c-bg)",
padding: "0.5rem",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof NavFooter>;
/** The editor's footer: credits, "Open PDF Processor", the account row. */
export const InEditor: Story = {};
/** The processor's footer. Same three boxes, opposite switch target. */
export const InProcessor: Story = {
args: { otherApp: { app: "editor", onOpen: () => {} } },
};
/** Self-hosted processor: no wallet, so no meter, and the link-account CTA
* rides along in the account box. */
export const WithLinkAccountCta: Story = {
args: {
credits: null,
otherApp: { app: "editor", onOpen: () => {} },
accountExtras: (
<NavItem
id="account-link"
label="Link Stirling account"
icon={<LinkIcon sx={{ fontSize: "1.1rem" }} />}
/>
),
},
};
/** Regression guard: the processor always passes its link-account CTA, but that
* component renders null once the org is linked. An element is truthy even
* when it renders nothing, so this must not leave a separator above the first
* visible row. */
export const ExtrasThatRenderNothing: Story = {
args: { accountExtras: <RendersNothing /> },
};
/** A real profile picture replaces the initials disc. */
export const WithProfilePicture: Story = {
args: {
displayName: "Ada Lovelace",
profilePictureUrl:
"data:image/svg+xml;utf8," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="black"/><circle cx="32" cy="24" r="12" fill="white"/><ellipse cx="32" cy="56" rx="20" ry="16" fill="white"/></svg>',
),
},
};
/** Credits running low — the dot and bar shift to the warning tone at 20% left. */
export const CreditsLow: Story = {
args: { credits: { remaining: 42, total: 500 } },
};
/** Allowance exhausted. */
export const CreditsExhausted: Story = {
args: { credits: { remaining: 0, total: 500 } },
};
/** Core OSS: no wallet, no second app, settings only. */
export const MinimalBuild: Story = {
args: { credits: null, otherApp: null },
};
/** No settings handler — the account row is inert identity, not a button. */
export const NoSettings: Story = {
args: { onOpenSettings: undefined },
};
/** Collapsed icon rail: labels become tooltips. */
export const Collapsed: Story = {
args: { collapsed: true },
decorators: [
(S) => (
<div
style={{
width: "3.5rem",
background: "var(--c-bg)",
padding: "0.5rem",
}}
>
<S />
</div>
),
],
};
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
/** The footer's tooltips need Mantine's theme context. */
function withProviders(ui: React.ReactNode) {
return <MantineProvider>{ui}</MantineProvider>;
}
function renderFooter() {
const { container } = render(
withProviders(
<NavFooter
displayName="admin"
onOpenSettings={() => {}}
credits={{ remaining: 247, total: 500 }}
otherApp={{ app: "processor", onOpen: () => {} }}
/>,
),
);
return container.querySelector(".nav-footer") as HTMLElement;
}
describe("NavFooter — enter animation", () => {
it("plays once per page session, not on every remount", () => {
// The rows are seeded from cache, so they're present at first paint. Every
// later mount — switching apps, remounting a view — would otherwise replay
// the fade on content that never changed, which reads as a twitch.
expect(renderFooter().dataset.animate).toBe("true");
cleanup();
expect(renderFooter().dataset.animate).toBeUndefined();
cleanup();
expect(renderFooter().dataset.animate).toBeUndefined();
});
});
describe("NavFooter — separators", () => {
it("never renders a divider beside a row that renders nothing", () => {
// Dividers are CSS between adjacent non-empty slots, so an extras element
// that returns null (the linked org's link-account CTA) can't leave a line.
const { container } = render(
withProviders(
<NavFooter
displayName="admin"
onOpenSettings={() => {}}
credits={null}
otherApp={null}
accountExtras={<>{null}</>}
/>,
),
);
const slots = container.querySelectorAll(".nav-footer__slot");
const filled = [...slots].filter((s) => s.childElementCount > 0);
expect(filled).toHaveLength(1);
expect(container.querySelectorAll(".nav-footer__divider")).toHaveLength(0);
});
});
@@ -0,0 +1,213 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import SettingsIcon from "@mui/icons-material/Settings";
import { Avatar, NavSurface } from "@app/ui";
import { BrandMark } from "@app/components/shared/BrandMark";
import { type AppSwitchTarget } from "@app/components/shared/AppSwitch";
import {
NavFooterCreditsRow,
type NavFooterCredits,
} from "@app/components/shared/navFooter/NavFooterCreditsRow";
import "@app/components/shared/navFooter/NavFooter.css";
export interface NavFooterAppLink {
/** The app this footer is NOT in — the one the row opens. */
app: AppSwitchTarget;
onOpen: () => void;
}
export interface NavFooterProps {
/** Name shown next to the avatar, and the source of its initials fallback. */
displayName: string;
/** Profile picture; initials are drawn when absent or the URL fails to load. */
profilePictureUrl?: string | null;
/** Omit to render the account row as static text (no settings affordance). */
onOpenSettings?: () => void;
/** Null/undefined hides the meter — builds with no wallet never show it. */
credits?: NavFooterCredits | null;
/** Opens the plan surface from the credits row; omit to leave it inert. */
onOpenPlan?: () => void;
/** Null/undefined hides the switch row — e.g. no access to the other app. */
otherApp?: NavFooterAppLink | null;
/** Extra rows above the account row (the self-hosted link-account CTA). */
accountExtras?: ReactNode;
/** Icon-rail state: labels collapse to tooltips. */
collapsed?: boolean;
className?: string;
}
/**
* Whether the enter animation has already played this page session. The rows
* are seeded from cache now, so they're present from first paint and every
* later mount — switching apps, remounting a view — would otherwise replay the
* animation on content that never changed, which reads as the UI twitching.
*/
let hasPlayedEnter = false;
/**
* The bottom section every sidebar ends with, shared by the editor and the
* processor so both present the same rows. ONE surface, hairline-separated, in
* this order:
*
* 1. caller-contributed rows (the self-hosted link-account CTA)
* 2. free credits remaining
* 3. "Open <the other app>"
* 4. the account row — avatar, name, settings
*
* Purely presentational: each app resolves its own identity, wallet and
* app-switch access and passes them in, so this file carries no build-specific
* gating. A row whose data is absent is dropped, and so is the separator that
* would have sat beside it.
*/
export function NavFooter({
displayName,
profilePictureUrl,
onOpenSettings,
credits,
onOpenPlan,
otherApp,
accountExtras,
collapsed = false,
className,
}: NavFooterProps) {
const { t } = useTranslation();
const [animate] = useState(() => {
if (hasPlayedEnter) return false;
hasPlayedEnter = true;
return true;
});
const settingsLabel = t("fileSidebar.openSettings", "Open settings");
const accountLabel = onOpenSettings
? `${displayName} - ${settingsLabel}`
: displayName;
// One surface, hairline-separated rows. Each row gets a slot; the separators
// are drawn by CSS between adjacent NON-EMPTY slots (see NavFooter.css), so a
// row that renders nothing — the link-account CTA returns null once the org is
// linked, and an element is truthy even then — can't leave a line behind.
const rows: Array<{ key: string; node: ReactNode }> = [];
if (accountExtras) rows.push({ key: "extras", node: accountExtras });
if (credits) {
rows.push({
key: "credits",
node: (
<NavFooterCreditsRow
credits={credits}
collapsed={collapsed}
label={t("navFooter.credits.label", "Free credits")}
onOpen={onOpenPlan}
/>
),
});
}
if (otherApp) {
rows.push({
key: "switch",
node: (
<Tooltip
label={openAppLabel(otherApp.app, t)}
position="right"
withinPortal
disabled={!collapsed}
>
<button
type="button"
className="nav-footer__row"
onClick={() => otherApp.onOpen()}
// Collapsed drops the visible label, so name the button here too.
aria-label={openAppLabel(otherApp.app, t)}
>
<span className="nav-footer__row-icon" aria-hidden>
<BrandMark height="1.125rem" />
</span>
{!collapsed && (
<>
<span className="nav-footer__row-label">
{openAppLabel(otherApp.app, t)}
</span>
{/* "Takes you there", not "opens a new tab" — both apps are
one SPA, so this navigates in place. */}
<span className="nav-footer__trailing" aria-hidden>
<ArrowForwardIcon sx={{ fontSize: "1rem" }} />
</span>
</>
)}
</button>
</Tooltip>
),
});
}
rows.push({
key: "account",
node: (
<Tooltip
label={accountLabel}
position="right"
withinPortal
disabled={!collapsed}
>
<button
type="button"
className="nav-footer__row nav-footer__account"
// Called with no args: handlers that take optional params (the
// processor's openSettings(section?)) must not receive the event.
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
disabled={!onOpenSettings}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={accountLabel}
>
{/* Decorative: the button's own label already names the account, so
an alt/label here would just repeat it to a screen reader. */}
<span aria-hidden>
<Avatar
size="sm"
name={displayName}
src={profilePictureUrl ?? undefined}
/>
</span>
{!collapsed && (
<span className="nav-footer__row-label sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<span className="nav-footer__trailing" aria-hidden>
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</span>
)}
</button>
</Tooltip>
),
});
return (
<NavSurface
className={["nav-footer", className ?? ""].filter(Boolean).join(" ")}
data-collapsed={collapsed || undefined}
data-animate={animate || undefined}
>
{rows.map((row) => (
<div key={row.key} className="nav-footer__slot">
{row.node}
</div>
))}
</NavSurface>
);
}
function openAppLabel(
app: AppSwitchTarget,
t: (key: string, fallback: string) => string,
): string {
return app === "editor"
? t("navFooter.openEditor", "Open PDF Editor")
: t("navFooter.openProcessor", "Open PDF Processor");
}
@@ -0,0 +1,83 @@
/* Free-credits meter inside the sidebar footer. The row base (padding, hover,
focus) comes from NavFooter.css; these rules are the meter itself. */
.nav-footer__credits {
flex-direction: column;
align-items: stretch;
gap: 0.375rem;
cursor: default;
}
/* Inert by default, so it must not read as hoverable; the actionable variant
opts back into the shared row hover. */
.nav-footer__credits:hover {
background: none;
}
.nav-footer__credits--actionable {
cursor: pointer;
}
.nav-footer__credits--actionable:hover {
background-color: var(--c-hover);
}
.nav-footer__credits-head {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.nav-footer__dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
flex-shrink: 0;
background-color: var(--c-success);
}
.nav-footer__dot[data-tone="warning"] {
background-color: var(--c-warning);
}
.nav-footer__dot[data-tone="danger"] {
background-color: var(--c-danger);
}
.nav-footer__credits-label {
flex: 1;
min-width: 0;
font-weight: 500;
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-footer__credits-count {
flex-shrink: 0;
color: var(--c-text-muted);
font-variant-numeric: tabular-nums;
}
/* ---- Collapsed rail ---- */
/* Rotated so the fill starts at 12 o'clock and runs clockwise. */
.nav-footer__credits-ring {
width: 1.25rem;
height: 1.25rem;
margin-inline: auto;
transform: rotate(-90deg);
}
.nav-footer__credits-ring-track,
.nav-footer__credits-ring-fill {
fill: none;
stroke-width: 3;
}
.nav-footer__credits-ring-track {
stroke: var(--c-surface-sunken);
}
.nav-footer__credits-ring-fill {
stroke-linecap: round;
}
@@ -0,0 +1,158 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import { ProgressBar } from "@app/ui";
import "@app/components/shared/navFooter/NavFooterCreditsRow.css";
export interface NavFooterCredits {
/** Free credits still available to spend. */
remaining: number;
/** Size of the free allowance — the "of N" denominator. */
total: number;
}
/** Remaining-credit bands, mirroring the usage meters' 80% / 100% thresholds. */
function creditsTone(remaining: number, total: number): string {
if (remaining <= 0) return "danger";
return total > 0 && remaining / total <= 0.2 ? "warning" : "success";
}
interface NavFooterCreditsRowProps {
credits: NavFooterCredits;
/** Icon rail: the figures drop and the bar alone carries the state. */
collapsed: boolean;
/** Row label, passed in so the meter owns no copy of its own. */
label: string;
/** Opens the plan surface. Omit to render the meter as inert text. */
onOpen?: () => void;
}
/**
* The free-credits meter as it appears in the sidebar footer: a state dot, the
* label, "X of Y" remaining, and a fill bar underneath. Figures are clamped
* here so a wallet that reports more remaining than the allowance (or negative)
* can't overflow the bar.
*
* Rendered as a {@code nav-footer__row}, so it inherits that row's metrics
* from NavFooter.css and only brings its own meter styling.
*/
export function NavFooterCreditsRow({
credits,
collapsed,
label,
onOpen,
}: NavFooterCreditsRowProps) {
const { t } = useTranslation();
const total = Math.max(0, credits.total);
const remaining = Math.min(Math.max(0, credits.remaining), total);
const tone = creditsTone(remaining, total);
const count = t("navFooter.credits.count", "{{remaining}} of {{total}}", {
remaining: remaining.toLocaleString(),
total: total.toLocaleString(),
});
return (
<Tooltip
label={`${label}: ${count}`}
position="right"
withinPortal
disabled={!collapsed}
>
<Row onOpen={onOpen} label={`${label}: ${count}`}>
{collapsed ? (
// The rail is one icon wide, so a full-width bar would read as a
// stray line; a ring carries the same fraction at icon size.
<CreditsRing
fraction={total > 0 ? remaining / total : 0}
tone={tone}
label={`${label}: ${count}`}
/>
) : (
<>
<div className="nav-footer__credits-head">
<span className="nav-footer__dot" data-tone={tone} aria-hidden />
<span className="nav-footer__credits-label">{label}</span>
<span className="nav-footer__credits-count">{count}</span>
</div>
<ProgressBar
value={total > 0 ? remaining / total : 0}
height={6}
color={`var(--c-${tone})`}
label={`${label}: ${count}`}
/>
</>
)}
</Row>
</Tooltip>
);
}
/** Icon-sized donut carrying the same remaining fraction as the expanded bar. */
function CreditsRing({
fraction,
tone,
label,
}: {
fraction: number;
tone: string;
label: string;
}) {
const RADIUS = 8;
const circumference = 2 * Math.PI * RADIUS;
const filled = Math.min(1, Math.max(0, fraction)) * circumference;
return (
<svg
className="nav-footer__credits-ring"
viewBox="0 0 20 20"
role="img"
aria-label={label}
>
<circle
className="nav-footer__credits-ring-track"
cx="10"
cy="10"
r={RADIUS}
/>
<circle
className="nav-footer__credits-ring-fill"
cx="10"
cy="10"
r={RADIUS}
stroke={`var(--c-${tone})`}
strokeDasharray={`${filled} ${circumference - filled}`}
/>
</svg>
);
}
/**
* The meter is a button only where there is a plan surface to open — otherwise
* it stays a plain div, so a build with nowhere to go doesn't advertise a
* click that does nothing.
*/
function Row({
onOpen,
label,
children,
}: {
onOpen?: () => void;
label: string;
children: ReactNode;
}) {
const className = `nav-footer__row nav-footer__credits${
onOpen ? " nav-footer__credits--actionable" : ""
}`;
if (!onOpen) return <div className={className}>{children}</div>;
return (
<button
type="button"
className={className}
onClick={() => onOpen()}
aria-label={label}
>
{children}
</button>
);
}
@@ -1,5 +1,7 @@
import { useContext, useEffect, useState } from "react";
import { Stack, Divider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ViewerContext } from "@app/contexts/ViewerContext";
import {
ChangeMetadataParameters,
createCustomMetadataFunctions,
@@ -19,6 +21,31 @@ interface ChangeMetadataSingleStepProps {
disabled?: boolean;
}
/**
* Pre-fills the form from the currently open document's existing metadata.
* Isolated in its own component so it only mounts where a ViewerProvider exists
* (the editor and the in-editor Automate modal). The pipeline builder has no
* viewer and no single "current document", so it is skipped there rather than
* crashing on useViewer.
*/
const MetadataPrefill = ({
onParameterChange,
onExtractingChange,
}: {
onParameterChange: ChangeMetadataSingleStepProps["onParameterChange"];
onExtractingChange: (extracting: boolean) => void;
}) => {
const { isExtractingMetadata } = useMetadataExtraction({
updateParameter: onParameterChange,
});
useEffect(() => {
onExtractingChange(isExtractingMetadata);
}, [isExtractingMetadata, onExtractingChange]);
return null;
};
const ChangeMetadataSingleStep = ({
parameters,
onParameterChange,
@@ -26,77 +53,85 @@ const ChangeMetadataSingleStep = ({
}: ChangeMetadataSingleStepProps) => {
const { t } = useTranslation();
// Auto-prefill reads the viewer/file contexts, which only exist in the editor.
// Gate on the viewer so the pipeline builder renders the fields without it.
const hasViewerContext = useContext(ViewerContext) !== null;
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
// Get custom metadata functions using the utility
const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } =
createCustomMetadataFunctions(parameters, onParameterChange);
// Extract metadata from uploaded files
const { isExtractingMetadata } = useMetadataExtraction({
updateParameter: onParameterChange,
});
const isDeleteAllEnabled = parameters.deleteAll;
const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata;
return (
<Stack gap="md">
{/* Delete All */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
</Text>
<DeleteAllStep
parameters={parameters}
<>
{hasViewerContext && (
<MetadataPrefill
onParameterChange={onParameterChange}
disabled={disabled}
onExtractingChange={setIsExtractingMetadata}
/>
</Stack>
<Divider />
{/* Standard Metadata Fields */}
)}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.standardFields.title", "Standard Metadata")}
</Text>
<StandardMetadataStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
{/* Delete All */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
</Text>
<DeleteAllStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</Stack>
<Divider />
{/* Standard Metadata Fields */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.standardFields.title", "Standard Metadata")}
</Text>
<StandardMetadataStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Document Dates */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.dates.title", "Document Dates")}
</Text>
<DocumentDatesStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Advanced Options */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.advanced.title", "Advanced Options")}
</Text>
<AdvancedOptionsStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
addCustomMetadata={addCustomMetadata}
removeCustomMetadata={removeCustomMetadata}
updateCustomMetadata={updateCustomMetadata}
/>
</Stack>
</Stack>
<Divider />
{/* Document Dates */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.dates.title", "Document Dates")}
</Text>
<DocumentDatesStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Advanced Options */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.advanced.title", "Advanced Options")}
</Text>
<AdvancedOptionsStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
addCustomMetadata={addCustomMetadata}
removeCustomMetadata={removeCustomMetadata}
updateCustomMetadata={updateCustomMetadata}
/>
</Stack>
</Stack>
</>
);
};
@@ -1,3 +1,4 @@
import { useContext, useRef } from "react";
import {
Stack,
Text,
@@ -7,6 +8,7 @@ import {
Divider,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { FilePicker } from "@app/ui/FilePicker";
import { ActionIcon } from "@app/ui/ActionIcon";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
@@ -15,7 +17,7 @@ import {
type OverlayMode,
} from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { FilesModalContext } from "@app/contexts/FilesModalContext";
import styles from "@app/components/tools/overlayPdfs/OverlayPdfsSettings.module.css";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
@@ -34,7 +36,12 @@ export default function OverlayPdfsSettings({
disabled = false,
}: OverlayPdfsSettingsProps) {
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
// Read optionally: the portal pipeline builder mounts no FilesModalProvider.
// Present (editor tool + Automate modal) -> keep the workspace file picker;
// absent (portal) -> fall back to the plain file input below.
const filesModal = useContext(FilesModalContext);
// Clears the FilePicker so the same file can be re-selected (Mantine resetRef).
const resetOverlayPicker = useRef<() => void>(null);
const handleOverlayFilesChange = (files: File[]) => {
onParameterChange("overlayFiles", files);
@@ -66,8 +73,8 @@ export default function OverlayPdfsSettings({
};
const handleOpenOverlayFilesModal = () => {
if (disabled) return;
openFilesModal({
if (disabled || !filesModal) return;
filesModal.openFilesModal({
customHandler: (files: File[]) => {
handleOverlayFilesChange([
...(parameters.overlayFiles || []),
@@ -77,6 +84,17 @@ export default function OverlayPdfsSettings({
});
};
const appendOverlayFiles = (files: File[]) => {
if (files.length === 0) return;
handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]);
resetOverlayPicker.current?.();
};
const overlayFilesButtonLabel =
parameters.overlayFiles?.length > 0
? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
: t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...");
return (
<Stack gap="md">
<Stack gap="xs">
@@ -183,17 +201,30 @@ export default function OverlayPdfsSettings({
<Text size="sm" fw={500}>
{t("overlay-pdfs.overlayFiles.label", "Overlay Files")}
</Text>
<Button
size="sm"
onClick={handleOpenOverlayFilesModal}
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{parameters.overlayFiles?.length > 0
? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
: t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...")}
</Button>
{filesModal ? (
<Button
size="sm"
onClick={handleOpenOverlayFilesModal}
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{overlayFilesButtonLabel}
</Button>
) : (
<FilePicker
multiple
accept="application/pdf"
onChange={appendOverlayFiles}
resetRef={resetOverlayPicker}
size="sm"
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{overlayFilesButtonLabel}
</FilePicker>
)}
{parameters.overlayFiles?.length > 0 &&
(() => {
@@ -41,7 +41,9 @@ interface FilesModalContextType {
setOnModalClose: (callback: () => void) => void;
}
const FilesModalContext = createContext<FilesModalContextType | null>(null);
export const FilesModalContext = createContext<FilesModalContextType | null>(
null,
);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
children,
@@ -30,4 +30,15 @@ describe("automatable tools", () => {
expect(offeredWithoutConfig).toEqual([]);
});
// Reorganize Pages has an automatable form (organization mode + page-order string) and a
// context-free settings component, but its registry entry once left automationSettings null,
// so both Automate and the pipeline builder showed "no configurable settings". Guard the wiring.
test("Reorganize Pages exposes automation settings so it is configurable, not no-settings", () => {
const { result } = renderHook(() => useTranslatedToolCatalog());
expect(
result.current.regularTools.reorganizePages?.automationSettings,
).toBeTruthy();
});
});
@@ -700,7 +700,10 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
endpoints: ["rearrange-pages"],
operationConfig: asRegistryConfig(reorganizePagesOperationConfig),
synonyms: getSynonyms(t, "reorganizePages"),
automationSettings: null,
automationSettings: lazySettings(
() =>
import("@app/components/tools/reorganizePages/ReorganizePagesSettings"),
),
},
scalePages: {
icon: (
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { accountService } from "@app/services/accountService";
export interface AccountIdentity {
/** Never empty — falls back to a generic "User" so a row is never blank. */
displayName: string;
profilePictureUrl: string | null;
isAnonymous: boolean;
}
/**
* The signed-in identity as the UI should draw it: one name and one picture,
* resolved the same way everywhere. Every surface that shows "who am I" (the
* editor and processor sidebar footers, the account settings page) reads this,
* so a user can't see one initial in the sidebar and a different one in
* settings.
*
* Resolution order for the name: the auth layer's own displayName (each layer
* derives it from its native user shape), then the proprietary REST endpoint,
* then a generic last resort.
*/
export function useAccountIdentity(): AccountIdentity {
const { t } = useTranslation();
const { config } = useAppConfig();
const { displayName: authDisplayName, isAnonymous } = useAuth();
const profilePictureUrl = useProfilePictureUrl();
const [accountUsername, setAccountUsername] = useState<string | null>(null);
useEffect(() => {
if (!config?.enableLogin) {
setAccountUsername(null);
return;
}
if (authDisplayName) {
// The auth context has a name; don't bother hitting the REST
// endpoint, but clear any stale cached value from a prior call.
setAccountUsername(null);
return;
}
accountService
.getAccountData()
.then((data) => {
// Always reflect the latest result - including clearing it on
// sign-out, when the endpoint returns no username (or 401s into
// the catch branch below). Without this, signing out would leave
// the old username on screen.
setAccountUsername(data?.username ?? null);
})
.catch(() => {
setAccountUsername(null);
});
}, [config?.enableLogin, authDisplayName]);
return {
displayName:
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"),
profilePictureUrl,
isAnonymous,
};
}
@@ -0,0 +1,12 @@
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
/**
* Free credits left on this team's allowance, for the sidebar footer meter.
* Null hides the meter entirely.
*
* Core has no wallet — self-hosted installs aren't metered — so there is
* nothing to show. Cloud builds override this with the live wallet figure.
*/
export function useFreeCreditsSummary(): NavFooterCredits | null {
return null;
}
@@ -0,0 +1,10 @@
/**
* Opens the plan surface behind the sidebar footer's free-credits row, or null
* when this build has none (the row is then inert text rather than a button).
*
* Core ships no wallet and no plan section, so there is nothing to open. Builds
* that meter usage override this with their own surface.
*/
export function useOpenPlan(): (() => void) | null {
return null;
}
@@ -0,0 +1,12 @@
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
/**
* The sibling app this build can switch to (editor ⇄ processor), or null when
* there is none. The single gate behind both the brand switcher and the
* sidebar footer's "Open ..." row, so the two can never disagree about access.
*
* Core ships no processor, so there is nothing to switch to.
*/
export function useOtherAppSwitch(): NavFooterAppLink | null {
return null;
}
+3
View File
@@ -6,5 +6,8 @@ export const qk = {
["editor", "endpointEnabled", endpoint] as const,
footerInfo: () => ["editor", "footerInfo"] as const,
groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const,
/** Keyed on the asking identity: two users must never share one answer. */
portalAccess: (userId: string | null) =>
["editor", "portalAccess", userId] as const,
users: () => ["editor", "users"] as const,
} as const;
@@ -0,0 +1,73 @@
/**
* Last-known sidebar-footer state, so the rows are correct at first paint
* instead of arriving a request later.
*
* The footer is mounted by both apps, and the editor and processor are separate
* React trees with separate query caches — so without this, every navigation
* between them (and every remount inside them) re-ran the fetches and the rows
* visibly popped in and shoved each other around. Persisting to storage rather
* than to an in-memory cache is what makes it survive that boundary, and a
* reload.
*
* Deliberately stale-then-revalidate: what's stored is only ever what the
* backend last said, every reader refetches immediately and overwrites, and
* nothing is gated on it — the processor enforces its own access server-side,
* and a stale credit figure is replaced within a second of the wallet landing.
*/
const CREDITS_KEY = "stirling.navFooter.credits";
const OTHER_APP_KEY = "stirling.navFooter.otherApp";
/** Figures, or null for a team that sees no meter at all (a paying one). */
export type CachedCredits = { remaining: number; total: number } | null;
function read(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
// Private mode / storage disabled — behave as a first-ever load.
return null;
}
}
function write(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// Nothing to do: the cache is an optimisation, never a correctness input.
}
}
/** `undefined` when this browser has never seen an answer. */
export function readCachedCredits(): CachedCredits | undefined {
const raw = read(CREDITS_KEY);
if (raw === null) return undefined;
if (raw === "none") return null;
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === "object" &&
parsed !== null &&
typeof (parsed as CachedCredits & object).remaining === "number" &&
typeof (parsed as CachedCredits & object).total === "number"
) {
return parsed as CachedCredits;
}
} catch {
// Corrupt entry — fall through and treat it as never-seen.
}
return undefined;
}
export function writeCachedCredits(credits: CachedCredits): void {
write(CREDITS_KEY, credits === null ? "none" : JSON.stringify(credits));
}
/** `undefined` when this browser has never seen an answer. */
export function readCachedOtherApp(): boolean | undefined {
const raw = read(OTHER_APP_KEY);
return raw === null ? undefined : raw === "true";
}
export function writeCachedOtherApp(canOpen: boolean): void {
write(OTHER_APP_KEY, String(canOpen));
}
@@ -71,6 +71,11 @@ html[data-app-theme="light"] {
var(--c-success) 10%,
var(--c-surface)
);
--c-warning-subtle: color-mix(
in srgb,
var(--c-warning) 10%,
var(--c-surface)
);
/* ── Decorative / brand / categorical palette ──────────────────────────
Fixed hues that intentionally do NOT follow the chosen accent: brand
+6
View File
@@ -46,6 +46,12 @@
height: 2.5rem;
font-size: 1rem;
}
/* Account-settings hero disc. */
.sui-avatar--xl {
width: 4.5rem;
height: 4.5rem;
font-size: 1.75rem;
}
.sui-avatar__img {
width: 100%;
+21 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import "@app/ui/Avatar.css";
export type AvatarSize = "xs" | "sm" | "md" | "lg";
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
export type AvatarTone =
| "blue"
| "purple"
@@ -23,10 +24,12 @@ export interface AvatarProps {
className?: string;
}
function initialsOf(name: string): string {
function avatarInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
// Single word (a username or an email) reads as one letter — two letters of
// "admin" ("AD") looks like a different person's initials, not a truncation.
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
@@ -43,6 +46,13 @@ export function Avatar({
ariaLabel,
className,
}: AvatarProps) {
// A picture URL that 404s (expired signed URL, deleted upload) must not leave
// an empty disc — fall back to the same initials the no-picture case shows, so
// every surface rendering this identity agrees on what it draws.
const [srcFailed, setSrcFailed] = useState(false);
useEffect(() => setSrcFailed(false), [src]);
const showImage = Boolean(src) && !srcFailed;
const classes = [
"sui-avatar",
`sui-avatar--${size}`,
@@ -53,11 +63,16 @@ export function Avatar({
.filter(Boolean)
.join(" ");
const content = src ? (
<img src={src} alt={ariaLabel ?? name} className="sui-avatar__img" />
const content = showImage ? (
<img
src={src}
alt={ariaLabel ?? name}
className="sui-avatar__img"
onError={() => setSrcFailed(true)}
/>
) : (
<span className="sui-avatar__initials" aria-hidden>
{initialsOf(name)}
{avatarInitials(name)}
</span>
);
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
import { useDefaultApp } from "@app/hooks/useDefaultApp";
export const DefaultAppBanner: React.FC = () => {
@@ -15,7 +15,7 @@ export const DefaultAppBanner: React.FC = () => {
const [sessionDismissed, setSessionDismissed] = useState(false);
return (
<InfoBanner
<AppBanner
icon="picture-as-pdf-rounded"
message={t(
"defaultApp.prompt.message",
@@ -0,0 +1,7 @@
/**
* SaaS has no link concept — the signed-in account IS the SaaS account, and the
* editor's cloud wallet hook is already in this build's {@code @app/*} cascade.
* Delegating to it means the processor footer and the editor footer share one
* wallet fetch and can't disagree, so there is nothing portal-specific to do.
*/
export { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
@@ -0,0 +1,11 @@
import { useCallback } from "react";
import { useUI } from "@portal/contexts/UIContext";
/**
* SaaS processor: the settings modal it hosts carries the same Plan section the
* editor opens, so the footer's credits row lands both apps in one place.
*/
export function useOpenPlan(): (() => void) | null {
const { openSettings } = useUI();
return useCallback(() => openSettings("plan"), [openSettings]);
}
@@ -123,8 +123,6 @@
}
.portal-sidebar[data-collapsed] .portal-sidebar__footer {
margin-inline: 0.375rem;
padding-inline: 0;
align-items: center;
}
.portal-sidebar__logo {
@@ -179,10 +177,8 @@
gap: 0.125rem;
}
/* The shared <NavFooter> brings its own boxes, padding and gap; the sidebar
only positions it. */
.portal-sidebar__footer {
margin: 0 0.625rem 0.75rem;
padding: 0.5rem 0.375rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@@ -2,6 +2,10 @@ 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 { 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";
@@ -10,7 +14,7 @@ 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 { CloseIcon, SettingsIcon } from "@portal/components/icons";
import { CloseIcon } from "@portal/components/icons";
import {
GROUP_PROCESSOR,
GROUP_PLATFORM,
@@ -41,6 +45,9 @@ export function Sidebar() {
const isMobile = useMediaQuery(MOBILE_QUERY, false, {
getInitialValueInEffect: false,
});
const { displayName, profilePictureUrl } = useAccountIdentity();
const credits = useFreeCreditsSummary();
const openPlan = useOpenPlan();
// Collapse is a desktop-only affordance: on mobile the sidebar is an
// off-canvas drawer, so the icon-rail state never applies there.
@@ -146,15 +153,17 @@ export function Sidebar() {
))}
</nav>
<NavSurface className="portal-sidebar__footer">
<LinkAccountFooterItem />
<NavItem
id="settings"
label={t("portal.nav.settings")}
icon={<SettingsIcon />}
onClick={() => openSettings()}
/>
</NavSurface>
<NavFooter
className="portal-sidebar__footer"
displayName={displayName}
profilePictureUrl={profilePictureUrl}
onOpenSettings={openSettings}
credits={credits}
onOpenPlan={openPlan ?? undefined}
otherApp={{ app: "editor", onOpen: goToEditor }}
accountExtras={<LinkAccountFooterItem />}
collapsed={collapsed}
/>
</aside>
);
}
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { Button, Card } from "@app/ui";
import { formatPeriodDate, MeterBar, meterState } from "@app/billing";
import { formatPeriodDate, MeterBar, remainingMeter } from "@app/billing";
import type { Wallet } from "@portal/api/billing";
/**
@@ -10,8 +10,8 @@ import type { Wallet } from "@portal/api/billing";
* - No bundle → a slim "Get 12 months for the price of 10" offer nudge with a
* "Review offer" CTA (the demo's commit-nudge card), shown only when a buyer
* ({@code onBuy}, leader) is present.
* - Bundle held → the capacity meter (fills as the pool is drawn down, so it
* warns as capacity runs low) plus a "Top up" action for the leader.
* - Bundle held → the capacity meter (drains towards empty as the pool is drawn
* down, so it warns as capacity runs low) plus a "Top up" action for the leader.
*
* Prepaid is consumed before metered billing and sits outside the spend limit, so
* it reads as its own dimension. Buying/topping up opens {@code BundleCheckoutModal}
@@ -55,8 +55,7 @@ export function PrepaidCapacityCard({
const remaining = wallet.prepaidUnitsRemaining;
const total = wallet.prepaidUnitsTotal;
const used = Math.max(0, total - remaining);
const { state, pct } = meterState(used, total);
const { state, pct } = remainingMeter(remaining, total);
const stateLabel =
state === "DEGRADED"
? t("portal.billing.prepaid.state.exhausted", "Used up")
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Card } from "@app/ui";
import { formatMinor, MeterBar, meterState } from "@app/billing";
import { formatMinor, MeterBar, remainingMeter } from "@app/billing";
import type { Wallet } from "@portal/api/billing";
import type { LocalUsage } from "@portal/api/link";
@@ -15,8 +15,10 @@ interface Props {
}
/**
* The free Processor-trial meter — "X / N free PDFs used" against the one-time
* grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
* The free Processor-trial meter — "X of N free PDFs left" against the one-time
* grant, with what has been used alongside as the status badge. The bar shows what
* is left, so it drains towards empty as the grant is spent.
* Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
* cloud plan page). The subscribed spend-vs-cap meter is a separate surface
* ({@code SpendLimitCard}); this card is only the free face.
*
@@ -30,7 +32,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
const pending = unsynced?.totalUnsyncedUnits ?? 0;
const used = wallet.billableUsed + pending;
const remaining = Math.max(0, wallet.freeRemaining - pending);
const { state, pct } = meterState(used, wallet.freeAllowance);
const { state, pct } = remainingMeter(remaining, wallet.freeAllowance);
const rate =
wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0
? wallet.pricePerDocMinor
@@ -76,11 +78,14 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
<MeterBar
state={state}
pct={pct}
barLabel={t("portal.billing.walletMeter.barAria", "Free PDFs used")}
figure={used.toLocaleString()}
barLabel={t(
"portal.billing.walletMeter.barAria",
"Free PDFs remaining",
)}
figure={remaining.toLocaleString()}
capSuffix={t(
"portal.billing.walletMeter.capSuffix",
"of {{allowance}} free PDFs used",
"of {{allowance}} free PDFs left",
{
count: wallet.freeAllowance,
allowance: wallet.freeAllowance.toLocaleString(),
@@ -88,11 +93,8 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
)}
statusLabel={t(
"portal.billing.walletMeter.statusLabel",
"{{remaining}} left",
{
count: remaining,
remaining: remaining.toLocaleString(),
},
"{{used}} used",
{ count: used, used: used.toLocaleString() },
)}
/>
</div>
@@ -1,10 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import { useEffect, useState } from "react";
import { render, screen } from "@testing-library/react";
import {
Component,
Suspense,
useEffect,
useState,
type ComponentType,
type ReactNode,
} from "react";
import { render, renderHook, screen, waitFor } from "@testing-library/react";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { Tooltip } from "@app/components/shared/Tooltip";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
import {
getExecutableTools,
type WorkingToolStep,
} from "@app/hooks/tools/shared/toolAutomation";
import {
asRegistryConfig,
type ErasedToolParams,
@@ -13,6 +26,10 @@ import {
import ConvertSettings from "@app/components/tools/convert/ConvertSettings";
import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOperation";
import { defaultParameters as convertDefaults } from "@app/hooks/tools/convert/useConvertParameters";
import ChangeMetadataSingleStep from "@app/components/tools/changeMetadata/ChangeMetadataSingleStep";
import { defaultParameters as changeMetadataDefaults } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings";
import { defaultParameters as overlayDefaults } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
// Override only useTranslation; keep the rest of react-i18next (initReactI18next et al.) real, so
@@ -21,6 +38,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
i18n: { language: "en-US", changeLanguage: vi.fn() },
}),
}));
@@ -63,6 +81,32 @@ const convertRegistry = {
},
} as unknown as Partial<ToolRegistry>;
// The real Change Metadata automation settings. Its editor variant auto-prefills the
// form from the open document via useViewer; that path is now gated on a ViewerProvider
// so it renders here (the portal mounts none) instead of crashing on useViewer.
const changeMetadataStep = {
support: "editable",
toolId: "changeMetadata",
params: changeMetadataDefaults,
} as unknown as WorkingToolStep;
const changeMetadataRegistry = {
changeMetadata: { automationSettings: ChangeMetadataSingleStep },
} as unknown as Partial<ToolRegistry>;
// The real Overlay PDFs automation settings. Its overlay-file picker uses the
// editor FilesModal when present; that read is now optional so the portal (which
// mounts no FilesModalProvider) renders a plain file input instead of crashing.
const overlayStep = {
support: "editable",
toolId: "overlayPdfs",
params: overlayDefaults,
} as unknown as WorkingToolStep;
const overlayRegistry = {
overlayPdfs: { automationSettings: OverlayPdfsSettings },
} as unknown as Partial<ToolRegistry>;
describe("PipelineStepSettings", () => {
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
expect(() =>
@@ -94,6 +138,36 @@ describe("PipelineStepSettings", () => {
expect(screen.getByText(/Convert from/)).toBeInTheDocument();
});
it("renders the Change Metadata tool's fields in the portal, with no ViewerProvider mounted", () => {
expect(() =>
render(
<PortalTestProviders>
<PipelineStepSettings
step={changeMetadataStep}
registry={changeMetadataRegistry}
onChange={() => {}}
/>
</PortalTestProviders>,
),
).not.toThrow();
expect(screen.getByText("Standard Metadata")).toBeInTheDocument();
});
it("renders the Overlay PDFs tool's fields in the portal, with no FilesModalProvider mounted", () => {
expect(() =>
render(
<PortalTestProviders>
<PipelineStepSettings
step={overlayStep}
registry={overlayRegistry}
onChange={() => {}}
/>
</PortalTestProviders>,
),
).not.toThrow();
expect(screen.getByText("Overlay Mode")).toBeInTheDocument();
});
// Reproduces the convert-in-pipeline bug: picking a source format fires several onParameterChange
// calls in one tick (set fromExtension, auto-target, reset options). If each rebuilt from the
// step snapshot captured at render they'd clobber each other and the earlier field would be lost.
@@ -148,3 +222,91 @@ describe("PipelineStepSettings", () => {
});
});
});
// Records a render crash and swallows it (renders nothing), so one broken tool is attributed by id
// instead of aborting the whole sweep - mirroring the portal's own ErrorBoundary around the builder.
class CaptureBoundary extends Component<
{ onError: (error: Error) => void; children: ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: Error) {
this.props.onError(error);
}
render() {
return this.state.failed ? null : this.props.children;
}
}
// Automated version of the manual "add every tool" sweep: render each tool's real automation
// settings in a portal-only context (the same Preferences + Sidebar + Suspense wrappers
// PipelineStepSettings uses, and NO editor providers) and fail listing any that throw. This is the
// guard that would have caught Change Metadata (useViewer) and Overlay PDFs (useFilesModalContext).
describe("PipelineStepSettings: every tool's settings render in the portal", () => {
it("renders each tool's automation settings without throwing", async () => {
const { result } = renderHook(() => useTranslatedToolCatalog());
const catalog = result.current.allTools;
// getExecutableTools is exactly what PipelineBuilder feeds its "Add a tool" picker, so this
// sweeps precisely the tools a user can add. Narrow to "editable" (renders a settings
// component); "noSettings"/"unsupported" steps show a Banner instead and can't crash.
const editableTools = getExecutableTools(catalog)
.filter((tool) => tool.support === "editable")
.map((tool) => [tool.toolId, catalog[tool.toolId]] as const)
.filter(([, entry]) => Boolean(entry?.automationSettings));
// Guard against the filter silently matching nothing (e.g. a registry-shape change).
expect(editableTools.length).toBeGreaterThan(10);
const failures: { toolId: string; message: string }[] = [];
for (const [toolId, entry] of editableTools) {
const Settings = entry.automationSettings as ComponentType<
ToolAutomationSettingsProps<ErasedToolParams>
>;
const params = (entry.operationConfig?.defaultParameters ??
{}) as ErasedToolParams;
const caught: { error: Error | null } = { error: null };
// The sentinel sibling commits only once the lazy Settings actually renders, so we wait for a
// real render (or a caught throw) - not just the providers' wrapper DOM.
const { unmount } = render(
<PortalTestProviders>
<PreferencesProvider>
<SidebarProvider>
<CaptureBoundary
onError={(error) => {
caught.error = error;
}}
>
<Suspense fallback={null}>
<Settings
parameters={params}
onParameterChange={() => {}}
disabled={false}
/>
<span data-testid={`rendered-${toolId}`} />
</Suspense>
</CaptureBoundary>
</SidebarProvider>
</PreferencesProvider>
</PortalTestProviders>,
);
await waitFor(() =>
expect(
caught.error !== null ||
screen.queryByTestId(`rendered-${toolId}`) !== null,
).toBe(true),
);
if (caught.error) {
failures.push({ toolId, message: caught.error.message });
}
unmount();
}
expect(failures).toEqual([]);
}, 30000);
});
@@ -0,0 +1,110 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { cleanup, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary";
const fetchWallet = vi.fn();
vi.mock("@portal/api/billing", () => ({
fetchWallet: () => fetchWallet(),
}));
function Probe() {
const credits = useFreeCreditsSummary();
return (
<span data-testid="credits">
{credits ? `${credits.remaining}/${credits.total}` : "none"}
</span>
);
}
function renderFor(initialState: LinkState) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<LinkProvider initialState={initialState}>
<Probe />
</LinkProvider>
</QueryClientProvider>,
).getByTestId("credits");
}
describe("useFreeCreditsSummary (self-hosted) — wallet behind the link gate", () => {
beforeEach(() => {
// The figures persist across mounts now, so isolate the suite from itself.
localStorage.clear();
fetchWallet.mockReset();
fetchWallet.mockResolvedValue({
status: "free",
freeRemaining: 247,
freeAllowance: 500,
});
});
it("unlinked reads no wallet at all", async () => {
const el = renderFor("unlinked");
await waitFor(() => expect(el.textContent).toBe("none"));
expect(fetchWallet).not.toHaveBeenCalled();
});
it("linked surfaces the free grant", async () => {
const el = renderFor("linked-free");
await waitFor(() => expect(el.textContent).toBe("247/500"));
});
it("hides the meter once the team subscribes", async () => {
// The grant is a lifetime pool that survives subscribing, so a paying team
// would otherwise sit on a spent meter forever.
fetchWallet.mockResolvedValue({
status: "subscribed",
freeRemaining: 0,
freeAllowance: 500,
});
const el = renderFor("linked-subscribed");
// The row holds its space while the wallet loads, then drops once the
// answer says this team is paying.
await waitFor(() => expect(el.textContent).toBe("none"));
});
it("hides the meter when the wallet read fails", async () => {
fetchWallet.mockRejectedValue(new Error("saas unreachable"));
const el = renderFor("linked-subscribed");
await waitFor(() => expect(el.textContent).toBe("none"));
});
it("ignores cached figures once the instance is unlinked", async () => {
// The cache survives an unlink and nothing rewrites it afterwards, so the
// linkage gate has to cover the seed too, not just the fetch.
const linked = renderFor("linked-free");
await waitFor(() => expect(linked.textContent).toBe("247/500"));
cleanup();
fetchWallet.mockClear();
const unlinked = renderFor("unlinked");
expect(unlinked.textContent).toBe("none");
expect(fetchWallet).not.toHaveBeenCalled();
});
it("shows the last known figures while the wallet reloads", async () => {
// What stops the row popping in — and resizing the footer — every time the
// processor mounts.
const el = renderFor("linked-free");
await waitFor(() => expect(el.textContent).toBe("247/500"));
cleanup();
let release: (v: unknown) => void = () => {};
fetchWallet.mockReturnValue(
new Promise((resolve) => {
release = resolve;
}),
);
const second = renderFor("linked-free");
// Seeded before the refetch lands...
expect(second.textContent).toBe("247/500");
release({ status: "free", freeRemaining: 12, freeAllowance: 500 });
// ...then updated in place, without the row ever being absent.
await waitFor(() => expect(second.textContent).toBe("12/500"));
});
});
@@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLink } from "@portal/contexts/LinkContext";
import { fetchWallet } from "@portal/api/billing";
import { qk } from "@portal/queries/keys";
import {
readCachedCredits,
writeCachedCredits,
type CachedCredits,
} from "@app/services/navFooterCache";
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
/**
* Free credits left on this team's allowance, for the processor's sidebar
* footer meter. Null hides the meter.
*
* This is the portal's own seam rather than the editor's {@code
* @app/hooks/useFreeCreditsSummary}, because self-hosted resolves {@code @app/*}
* as proprietary → core: the cloud wallet hook isn't in that cascade, and the
* implementation can't move down into proprietary either, since core/desktop
* builds ship no portal and must never resolve {@code @portal}. Keeping it here
* means only builds that actually have a processor pull in the wallet read.
*
* Self-hosted reads the same {@code GET /api/v1/payg/wallet} the Usage page's
* trial meter renders — {@code apiClient.saas} with the admin's Supabase JWT,
* since the wallet lives in the cloud even when the instance doesn't. Gated on
* linkage: an unlinked instance has no wallet to read.
*
* Free teams only, matching the editor and the Plan page. The grant is a
* lifetime pool that survives subscribing, so a paying team would otherwise sit
* on a permanent "0 of 500" in red; their usage lives on Usage & Billing.
*/
export function useFreeCreditsSummary(): NavFooterCredits | null {
const { isLinked } = useLink();
// Shared query key, so the footer rides the same cached snapshot as any other
// wallet reader rather than adding a fetch per mount.
const { data: wallet } = useQuery({
queryKey: qk.wallet(isLinked),
queryFn: fetchWallet,
enabled: isLinked,
});
// Shared with the editor's seam, so crossing between the two apps shows the
// figures the other one last saw rather than re-fetching into an empty row.
const [seed] = useState(readCachedCredits);
const live: CachedCredits | undefined = !wallet
? undefined
: wallet.status === "subscribed"
? null
: { remaining: wallet.freeRemaining, total: wallet.freeAllowance };
useEffect(() => {
// Only once linked: an unlinked instance never asks, so it has no answer of
// its own and must not overwrite what the editor recorded.
if (isLinked && live !== undefined) writeCachedCredits(live);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet, isLinked]);
// Linkage gates the seed as well as the fetch. The cache outlives an unlink
// — nothing refetches or rewrites it once the instance stops asking — so
// without this an unlinked instance would keep showing the figures from when
// it was linked, indefinitely.
if (!isLinked) return null;
return (live !== undefined ? live : seed) ?? null;
}
@@ -0,0 +1,13 @@
import { useCallback } from "react";
import { useView } from "@portal/contexts/ViewContext";
/**
* Self-hosted processor: settings carries no Plan section (it is a cloud
* surface, and this build's registry has none), so the footer's credits row
* opens the portal's own Usage & Billing view instead — the same figures, on
* the surface this flavor actually owns.
*/
export function useOpenPlan(): (() => void) | null {
const { setActiveView } = useView();
return useCallback(() => setActiveView("usage"), [setActiveView]);
}
@@ -20,6 +20,8 @@ export const qk = {
// Keyed on linkage: an unlinked account has no deal to read, so linking must not
// serve the unlinked (null) snapshot back from cache.
procurement: (linked: boolean) => ["portal", "procurement", linked] as const,
// Same reasoning: an unlinked instance has no wallet in the cloud.
wallet: (linked: boolean) => ["portal", "wallet", linked] as const,
// Tier-dependent
documents: (tier: Tier) => ["portal", "documents", tier] as const,
@@ -163,6 +163,10 @@ export const mantineTheme = createTheme({
CloseButton: { defaultProps: { "aria-label": "Close" } },
Modal: { defaultProps: { closeButtonProps: { "aria-label": "Close" } } },
Drawer: { defaultProps: { closeButtonProps: { "aria-label": "Close" } } },
// The portal's md default radius (8px) is right for cards and buttons but
// rounds a 20px checkbox into a circle. Pin it to the smaller radius the
// editor's checkboxes use so the box reads as a checkbox.
Checkbox: { styles: { input: { borderRadius: "var(--radius-sm)" } } },
},
fontFamily: "var(--font-sans)",
fontFamilyMonospace: "var(--font-mono)",
@@ -293,6 +293,29 @@ export function computeBundleQuote(
export type MeterState = "FULL" | "WARNED" | "DEGRADED";
/**
* Meter for a balance that is spent DOWN — a free grant, a prepaid pool. The
* bar shows what is LEFT, so full reads as "plenty" and empty as "none", which
* is how the sidebar footer's credits row reads and the only direction that
* matches a figure quoting the remainder.
*
* The state bands still key on consumption, so the tone is unchanged: amber
* once 80% is gone, red once it's exhausted. Meters for money SPENT against a
* cap keep using {@link meterState} directly — there a full bar correctly means
* "at your ceiling".
*/
export function remainingMeter(
remaining: number,
total: number,
): { state: MeterState; pct: number } {
const { state } = meterState(Math.max(0, total - remaining), total);
const pct =
total > 0
? Math.min(100, Math.max(0, (Math.max(0, remaining) / total) * 100))
: 0;
return { state, pct };
}
/** Warn (≥80%) / degrade (≥100%) band for a usage meter; mirrors the BE thresholds. */
export function meterState(
used: number,
@@ -14,6 +14,7 @@ export {
docCapForMoney,
formatPeriodDate,
meterState,
remainingMeter,
PREPAID_MONTHS_GRANTED,
PREPAID_MONTHS_PAID,
PDFS_PER_USER_MONTH,
@@ -1,15 +1,21 @@
import { useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/context";
import { Logo } from "@app/ui/Logo";
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
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 { portalAccess } = useAuth();
const navigate = useNavigate();
const otherApp = useOtherAppSwitch();
if (!portalAccess) {
if (!otherApp) {
return (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
@@ -22,7 +28,7 @@ export function AppSwitcher({ collapsed }: AppSwitcherProps) {
return (
<BrandSwitcher
current="editor"
onSwitch={() => navigate(PORTAL_BASENAME)}
onSwitch={otherApp.onOpen}
collapsed={collapsed}
/>
);
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate, useLocation } from "react-router-dom";
import { isAuthRoute } from "@core/constants/routes";
import { useCheckout } from "@app/contexts/CheckoutContext";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
import {
SERVER_LICENSE_REQUEST_EVENT,
type ServerLicenseRequestPayload,
@@ -300,7 +300,7 @@ const UpgradeBanner: React.FC = () => {
);
return (
<InfoBanner
<AppBanner
icon="warning-rounded"
tone="warning"
title={t(
@@ -312,13 +312,6 @@ const UpgradeBanner: React.FC = () => {
buttonIcon="info-rounded"
onButtonClick={buttonText ? handleSeeInfo : undefined}
dismissible={false}
minHeight={60}
background="#FFF4E6"
borderColor="var(--mantine-color-orange-7)"
textColor="#9A3412"
iconColor="#EA580C"
buttonVariant="filled"
buttonColor="orange.7"
/>
);
};
@@ -341,7 +334,7 @@ const UpgradeBanner: React.FC = () => {
return (
<>
{friendlyVisible && (
<InfoBanner
<AppBanner
icon="stars-rounded"
title={t("upgradeBanner.title", "Upgrade to Server Plan")}
message={t(
@@ -353,14 +346,7 @@ const UpgradeBanner: React.FC = () => {
onButtonClick={handleUpgrade}
onDismiss={handleFriendlyDismiss}
show={friendlyVisible}
background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
borderColor="transparent"
textColor="#fff"
iconColor="#fff"
closeIconColor="#fff"
buttonVariant="filled"
buttonColor="blue"
minHeight={48}
tone="promo"
compact
/>
)}
@@ -12,7 +12,7 @@ import AvailablePlansSection from "@app/components/shared/config/configSections/
import StaticPlanSection from "@app/components/shared/config/configSections/plan/StaticPlanSection";
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
import { alert } from "@app/components/toast";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import {
getPreferredCurrency,
@@ -200,7 +200,7 @@ const AdminPlanSection: React.FC = () => {
<LoginRequiredBanner show={!loginEnabled} />
{shouldShowLicenseWarning && (
<InfoBanner
<AppBanner
icon="warning-rounded"
tone="warning"
title={t(
@@ -215,13 +215,6 @@ const AdminPlanSection: React.FC = () => {
buttonIcon="upgrade-rounded"
onButtonClick={scrollToPlans}
dismissible={false}
minHeight={68}
background="#FFF4E6"
borderColor="var(--mantine-color-orange-7)"
textColor="#9A3412"
iconColor="#EA580C"
buttonVariant="filled"
buttonColor="orange.7"
/>
)}
@@ -0,0 +1,15 @@
import { useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/context";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
/**
* Self-hosted: the Spring session carries `portalAccess`, so the switch to the
* processor is offered exactly when that flag is set.
*/
export function useOtherAppSwitch(): NavFooterAppLink | null {
const { portalAccess } = useAuth();
const navigate = useNavigate();
if (!portalAccess) return null;
return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) };
}
@@ -1,41 +0,0 @@
import { useNavigate } from "react-router-dom";
import { Logo } from "@app/ui/Logo";
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
import { usePortalAccess } from "@app/hooks/usePortalAccess";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
/**
* SaaS sidebar brand header. When the backend says this user can open the
* processor (`/api/v1/auth/me` → `portalAccess` — the exact signal the
* processor's own gate uses), the Stirling logo doubles as the
* editor⇄processor switcher: the mark morphs into a chevron and opens the
* switch menu (same BrandSwitcher the processor sidebar uses). Users without
* access get a plain logo.
*
* Deliberately NOT gated on the editor's Supabase auth context: that context
* never fetches /me, so it can't know about portal access (and its session
* state doesn't always mirror the backend login that actually grants it).
*/
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
const portalAccess = usePortalAccess();
const navigate = useNavigate();
if (!portalAccess) {
return (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
return (
<BrandSwitcher
current="editor"
onSwitch={() => navigate(PORTAL_BASENAME)}
collapsed={collapsed}
/>
);
}
@@ -1,7 +1,6 @@
import React, { useState } from "react";
import {
Alert,
Avatar,
Divider,
Group,
Image,
@@ -11,10 +10,12 @@ import {
TextInput,
Modal,
} from "@mantine/core";
import { Avatar } from "@app/ui/Avatar";
import { Button as DSButton } from "@app/ui/Button";
import { FilePicker } from "@app/ui/FilePicker";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
import {
isUserAnonymous,
linkEmailIdentity,
@@ -46,6 +47,8 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
refreshProfilePicture,
refreshProfilePictureMetadata,
} = useAuth();
// Same name + initials the sidebar footer draws, so the two discs agree.
const { displayName } = useAccountIdentity();
const PROFILE_BUCKET = "profile-pictures";
@@ -67,7 +70,6 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
const provider = profilePictureMetadata?.provider;
const profilePath = user ? `${user.id}/avatar` : null;
const profileInitial = user?.email?.trim()?.charAt(0)?.toUpperCase() || "U";
const handleProfileUpload = async (file: File | null) => {
if (!file || !user || !profilePath) {
@@ -410,12 +412,9 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Group align="center" gap="md">
<Avatar
src={profilePictureUrl || undefined}
radius="xl"
size={72}
color="blue"
>
{profileInitial}
</Avatar>
name={displayName}
size="xl"
/>
<div
style={{
display: "flex",
@@ -450,12 +449,9 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
<Group align="center" gap="md">
<Avatar
src={profilePictureUrl || undefined}
radius="xl"
size={72}
color="blue"
>
{profileInitial}
</Avatar>
name={displayName}
size="xl"
/>
<div
style={{
display: "flex",
@@ -0,0 +1,16 @@
import { useNavigate } from "react-router-dom";
import { usePortalAccess } from "@app/hooks/usePortalAccess";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
import { type NavFooterAppLink } from "@app/components/shared/navFooter/NavFooter";
/**
* SaaS: the editor's Supabase context never fetches /me, so processor access
* comes from the backend via {@link usePortalAccess} — the same signal the
* processor's own gate uses.
*/
export function useOtherAppSwitch(): NavFooterAppLink | null {
const portalAccess = usePortalAccess();
const navigate = useNavigate();
if (!portalAccess) return null;
return { app: "processor", onOpen: () => navigate(PORTAL_BASENAME) };
}
@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { renderHook as baseRenderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const get = vi.fn();
let currentUserId: string | null = null;
@@ -20,10 +22,28 @@ function meReturning(portalAccess: boolean) {
return { data: { user: { portalAccess } } };
}
// A fresh client per render, so one test's cached answer can't satisfy the
// next — each case exercises a cold cache unless it deliberately shares one.
let client: QueryClient;
function renderHook<T>(cb: () => T) {
return baseRenderHook(cb, {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
});
}
describe("usePortalAccess", () => {
beforeEach(() => {
// The hook now remembers the last answer across mounts, so without this a
// prior test's result seeds the next one.
localStorage.clear();
get.mockReset();
currentUserId = null;
client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
});
it("reports the backend's answer for the signed-in user", async () => {
@@ -82,12 +102,31 @@ describe("usePortalAccess", () => {
expect(first.result.current).toBe(false);
first.unmount();
// The failure isn't sticky.
// The failure isn't sticky — a cold cache asks again.
client.clear();
get.mockResolvedValue(meReturning(true));
const second = renderHook(() => usePortalAccess());
await waitFor(() => expect(second.result.current).toBe(true));
});
it("shows the last known answer at first paint, then revalidates", async () => {
// What stops the switcher and the footer's "Open ..." row popping in a
// request late on every mount.
currentUserId = "admin-1";
get.mockResolvedValue(meReturning(true));
const first = renderHook(() => usePortalAccess());
await waitFor(() => expect(first.result.current).toBe(true));
first.unmount();
client.clear();
get.mockResolvedValue(meReturning(false));
const second = renderHook(() => usePortalAccess());
// Seeded from the remembered answer before the request lands...
expect(second.result.current).toBe(true);
// ...and corrected once the backend disagrees.
await waitFor(() => expect(second.result.current).toBe(false));
});
it("ignores a response that lands after unmount", async () => {
currentUserId = "admin-1";
let resolveMe: (v: unknown) => void = () => {};
@@ -1,52 +1,64 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import {
readCachedOtherApp,
writeCachedOtherApp,
} from "@app/services/navFooterCache";
import { qk } from "@app/query/keys";
async function fetchPortalAccess(): Promise<boolean> {
const res = await apiClient.get<{ user?: { portalAccess?: boolean } }>(
"/api/v1/auth/me",
);
return res.data.user?.portalAccess === true;
}
/**
* Whether the current user can open the processor (admin portal), straight
* from the backend (`/api/v1/auth/me` → `portalAccess`) — the same signal the
* processor's own SaasPortalGate uses. Components that must mirror processor
* access (e.g. the sidebar's editor⇄processor switcher) ask here.
* access (the sidebar's editor⇄processor switcher and its footer row) ask here.
*
* The editor's Supabase auth context can't *answer* this — it never fetches
* /me — so it is used only to identify who is asking. Keying the effect on
* that identity is what keeps the answer per-user: the SPA can swap users
* /me — so it is used only to identify who is asking. That identity is the
* cache key, which is what keeps the answer per-user: the SPA can swap users
* without a reload (Supabase fires SIGNED_OUT/SIGNED_IN in place; only the
* settings Logout button hard-navigates), so any answer held beyond the
* current identity would leak to whoever signs in next.
* settings Logout button hard-navigates), and a keyed cache addresses each
* identity separately rather than holding one answer that would have to be
* invalidated on the swap — the bug class this hook once had.
*
* Deliberately unmemoised beyond the mount: the one consumer (the sidebar
* switcher) mounts once, so a cross-mount cache would only add user-scoped
* state that has to be invalidated on identity change — the bug class this
* hook already had once. Guests skip the request entirely.
* Cached through the app query client, so leaving the editor for the processor
* and coming back resolves from cache: the switcher is there on first paint
* instead of appearing a request later. Guests skip the request entirely.
*/
export function usePortalAccess(): boolean {
const { user } = useAuth();
const userId = user?.id ?? null;
const [access, setAccess] = useState(false);
// The query cache is per-tree and per-load, so it can't help a cold start or
// the hop into the processor, which mounts its own client. Seed from the last
// answer this browser saw so the switcher and the footer's "Open ..." row are
// there at first paint. Marked ancient so it still revalidates immediately.
const [seed] = useState(readCachedOtherApp);
const { data, isSuccess } = useQuery({
queryKey: qk.portalAccess(userId),
queryFn: fetchPortalAccess,
// Signed out: nothing to ask, and any previous answer is void.
enabled: userId !== null,
// Backend unreachable or guest (401) means no access now; a later refetch
// asks again rather than trusting the failure.
retry: false,
initialData: seed,
initialDataUpdatedAt: 0,
});
useEffect(() => {
// Signed out: nothing to ask, and any previous answer is void.
if (userId === null) {
setAccess(false);
return;
}
// Only a real answer is recorded — a failed probe is not one, so the next
// mount trusts the last backend response rather than a network blip.
if (isSuccess && data !== undefined) writeCachedOtherApp(data);
}, [isSuccess, data]);
let cancelled = false;
apiClient
.get<{ user?: { portalAccess?: boolean } }>("/api/v1/auth/me")
.then((res) => {
if (!cancelled) setAccess(res.data.user?.portalAccess === true);
})
.catch(() => {
// Backend unreachable or guest (401): no access now; a remount or
// identity change asks again rather than trusting a failure.
if (!cancelled) setAccess(false);
});
return () => {
cancelled = true;
};
}, [userId]);
return access;
return data === true;
}
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { expectConsole } from "@app/tests/failOnConsole";
const get = vi.fn();
vi.mock("@app/services/apiClient", () => ({
default: { get: (...args: unknown[]) => get(...args) },
}));
vi.mock("@app/hooks/walletDevPreview", () => ({
getWalletDevPreview: () => null,
}));
vi.mock("@app/services/billing", () => ({ createPortalSession: vi.fn() }));
vi.mock("@app/platform/openExternal", () => ({ openExternal: vi.fn() }));
const { useWallet } = await import("@app/hooks/useWallet");
/** Full enough for the hook's deep-compare, which reads every field. */
function walletWith(freeRemaining: number) {
return {
data: {
teamId: 1,
status: "free",
role: "leader",
billingPeriodStart: "2026-08-01",
billingPeriodEnd: "2026-08-31",
billableUsed: 500 - freeRemaining,
billableLimit: 500,
freeAllowance: 500,
freeRemaining,
pricePerDocMinor: 2,
bundleRatePerCreditMinor: null,
currency: "usd",
estimatedBillMinor: 0,
capUsd: null,
noCap: false,
stripeSubscriptionId: null,
spendUnitsThisPeriod: 0,
docsProcessedThisPeriod: 0,
uniquePdfsThisPeriod: 0,
sizeMultiplierPdfsThisPeriod: 0,
billingMode: "metered",
prepaidUnitsRemaining: 0,
prepaidUnitsTotal: 0,
prepaidExpiresAt: null,
recent: [],
members: [],
categoryBreakdown: { api: 0, ai: 0, automation: 0 },
categoryDocs: { api: 0, ai: 0, automation: 0 },
},
};
}
describe("useWallet — keeping the figures fresh", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
get.mockReset();
get.mockResolvedValue(walletWith(500));
});
afterEach(() => vi.useRealTimers());
it("re-reads the wallet on the poll interval", async () => {
const { result } = renderHook(() => useWallet());
await waitFor(() => expect(result.current.wallet).not.toBeNull());
expect(get).toHaveBeenCalledTimes(1);
get.mockResolvedValue(walletWith(480));
await act(async () => {
vi.advanceTimersByTime(30_000);
});
await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(480));
});
it("polls silently, so consumers gating on loading/error don't flicker", async () => {
const { result } = renderHook(() => useWallet());
await waitFor(() => expect(result.current.wallet).not.toBeNull());
// A poll that fails must leave the last good snapshot, and must not raise
// `error` — Plan swaps a working page for an alert on that.
get.mockRejectedValue(new Error("network blip"));
await act(async () => {
vi.advanceTimersByTime(30_000);
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.wallet?.freeRemaining).toBe(500);
});
it("settles loading when a silent poll supersedes an in-flight visible load", async () => {
// The mount load raises `loading`; a poll firing before it lands cancels it.
// If clearing the flag were the silent load's to skip, both would decline
// and `loading` would stay true forever — which permanently suppresses the
// limit modals, since they do `if (loading || !wallet) return null`.
const visibility = vi.spyOn(document, "visibilityState", "get");
visibility.mockReturnValue("visible");
let landMount: (v: unknown) => void = () => {};
get.mockReturnValueOnce(
new Promise((resolve) => {
landMount = resolve;
}),
);
const { result } = renderHook(() => useWallet());
expect(result.current.loading).toBe(true);
get.mockResolvedValue(walletWith(470));
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
await act(async () => {
landMount(walletWith(500));
});
await waitFor(() => expect(result.current.wallet?.freeRemaining).toBe(470));
expect(result.current.loading).toBe(false);
visibility.mockRestore();
});
it("clears a stale error once a silent poll succeeds", async () => {
// The visible mount load failing is meant to be logged; only the silent
// retries stay quiet.
expectConsole.warn(/\[useWallet\] fetch failed/);
get.mockRejectedValueOnce(new Error("network blip"));
const { result } = renderHook(() => useWallet());
await waitFor(() => expect(result.current.error).not.toBeNull());
get.mockResolvedValue(walletWith(500));
await act(async () => {
vi.advanceTimersByTime(30_000);
});
await waitFor(() => expect(result.current.error).toBeNull());
expect(result.current.wallet?.freeRemaining).toBe(500);
});
it("stops polling while the tab is hidden and re-reads on return", async () => {
const visibility = vi.spyOn(document, "visibilityState", "get");
visibility.mockReturnValue("visible");
const { result } = renderHook(() => useWallet());
await waitFor(() => expect(result.current.wallet).not.toBeNull());
const afterMount = get.mock.calls.length;
visibility.mockReturnValue("hidden");
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
vi.advanceTimersByTime(120_000);
});
expect(get).toHaveBeenCalledTimes(afterMount);
visibility.mockReturnValue("visible");
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
await waitFor(() => expect(get.mock.calls.length).toBe(afterMount + 1));
visibility.mockRestore();
});
});