Per Connor: the enterprise-agreement PDF is rendered server-side, so a
download failure means the render service (WeasyPrint) is unavailable —
not something the buyer can retry around with a browser print. Removed
the hidden-iframe print fallback and now show a clear error instead. The
flow can be exercised end to end once WeasyPrint is available (Docker or
a local install).
AI features (per Connor):
- The "Anthropic — receives prompts and queries only" representation was
not truthful: document text/excerpts are sent to Claude to perform the
requested task. Aligned Anthropic's description with Voyage's (both
receive extracted document text/excerpts, not whole files) across the
EULA §8, DPA §C5, and the subprocessor page.
- Replaced the absolute "your files are never transmitted to any AI
provider" with an accurate statement: only the document text/excerpts
needed for a requested feature are sent, never whole files, and only
when that feature runs.
- Made explicit that AI features are optional and can be disabled.
Threshold billing (EULA §4.4): removed — not a shipped capability
(its source note said "remove if not shipped"). Renumbered 4.5/4.6.
FLAG for Matt/counsel: this diverges from the source bundle's "no customer
files ever transmitted to AI providers / Anthropic prompts-only" line,
which was marked "verified against signed provider agreements." The
no-training representation is retained but must be re-verified now that
document content is acknowledged as sent to Anthropic. Still DRAFT.
A clause-by-clause review against the CEO's source drafts found a few
places where the rendered copy had strayed from the originals beyond the
intended cleaning (token fills + stripping internal [COUNSEL]/[MATT]/
[Connor:] markers). Restoring exact wording:
- DPA C5: keep as operative prose (not the source's editorial "publish a
page" bracket) but match the original phrasing exactly — "as
independent controller", "Claude/embedding models — receives ...",
comma-separated list — and re-add the dropped "(verified against the
signed provider agreements, Jul 10, 2026)".
- EULA intro: re-add the dropped substantive text, including "For
customers under a signed Stirling Enterprise Agreement, that agreement
controls."
- MSA intro: revert to the source's "incorporated by reference" (drop the
added "to the extent stated in Section 9.1" qualifier).
- Restore bold emphasis on MSA 1.3 and DPA C1/C5/C6 sentences.
- SLA: "MSA Section 5.2" -> "MSA §5.2"; Order Form menu: "included as
elected" -> "include as elected".
No clause, number, price, or obligation changed — this only realigns
prose the review flagged as non-verbatim. Still DRAFT, attorney review
required before execution.
The "Agreement" download at the sign step hit the Java backend's
/agreement/document/pdf, which renders the agreement markdown via
WeasyPrint. That runtime isn't present in every environment (local dev,
any deploy without it), so the endpoint 404s and the download silently
failed. Worse, the failure set the shared `error` flag, so the buyer saw
"Could not record your signature" — an unrelated, alarming message.
- Separate the download error from the sign error (own state + own copy).
- On any download failure, fall back to the browser's print-to-PDF on the
already-rendered agreement body, via a hidden iframe so it isn't
popup-blocked. Production still serves the branded WeasyPrint PDF; the
fallback only triggers when that path is unavailable. The printed draft
carries the same "Draft — for review" notice.
The quote-builder form (.portal-qb__*) was ported from the marketing
prototype with hard-coded light colours (#fff / #e3e1dc / #37352f /
#9b9a97 ...), so it rendered light in both themes. The trial-setup modal
reuses these classes, which is where it was most visible: white option
cards, a white team-size input, and a white EULA row inside an otherwise
dark modal.
Swap the surfaces, borders, and text to the portal theme tokens
(--color-surface / --color-border(-input,-light) / --color-text-1/3/4 /
--color-bg-muted) so they flip with the theme. Selected/accent states
keep the #2383e2 blue but use a translucent rgba tint instead of the
opaque light-blue fill so they read on dark. The itemised "paper" quote
preview is left intentionally light (it is a document surface).
The Documents agreement row only showed "Download" when a signed PDF was
already stored, which happens at signing only if the render runtime
(WeasyPrint) succeeded — so a signed agreement wrongly stayed on "View" when
the render was unavailable. Signal "signed" from the signature record existing
(not the stored blob), and resolve the PDF at download time: serve the stored
artifact, or re-render it from the signature's details if the blob is missing.
Now: signed -> Download; the download still needs the render runtime available
at sign or download time to produce the file.
The Documents modal gated the agreement + quote on isIssued (quote status
sent/open), which flips false once the quote is accepted — so after signing
they wrongly read 'available once your quote is issued'. Gate on the quote's
stripeQuoteId instead, which is set at issuance and persists through
acceptance, so the reference stays available throughout the journey.
- Details step: prefill the contact email from the linked account so the buyer
doesn't retype it (fresh quotes only; re-edits keep their saved value).
- Sign step: replace the single "Download PDF" (which only fetched the quote)
with two download-icon buttons — "Agreement" and "Quote". Agreement downloads
the current unsigned MSA (new GET /agreement/document/pdf, rendered on the fly
from the filled agreement, resilient to WeasyPrint being absent); Quote keeps
the existing quote-PDF download. Dropped the now-unused milestone.download key.
Verified: saas compiles; portal typecheck, eslint, prettier, toml-sort,
translation audits, 176 tests.
The Enterprise Agreement row now has three states: once a quote is issued it
shows "View", which jumps to the agreement/sign stage in the flow (rather than
a read-only render), so it's reachable for review/signing from generation
onward; once signed it shows "Download" for the stored signed PDF; before a
quote exists it reads "available later". Dropped the now-unused laterSigned key.
A "Documents" quick action on the deal-status hero opens a modal that
collects the deal's paperwork in one place, reachable throughout the journey
(not tied to the current stage) as a secondary reference. The per-stage
download buttons are unchanged and remain the primary path.
- Each row downloads/views the real artifact when available and reads as
"available later" until then: Enterprise Agreement (signed PDF), Quote
(PDF), Invoice (Stripe), and the reference docs EULA / SLA exhibit /
Subprocessors (from the legal registry).
- Reuses the existing handlers/endpoints (signed-agreement PDF, quote PDF,
legal registry); front-end only, no backend change.
Verified: portal typecheck, eslint, prettier, dpdm (no cycles), toml-sort,
translation audits, 174 portal tests.
Adds a "Download signed agreement" button so the buyer can retrieve the
final signed PDF (the sign step already renders + stores it; there was a
backend endpoint but no UI to reach it).
- Snapshot exposes agreementSignedVersion (the label of the latest signature
that has a stored PDF) via a projection query that avoids loading the PDF
bytes on the frequently-polled snapshot.
- The button appears on the payment and live stages only when a downloadable
PDF exists, and fetches it as an auth-gated blob (apiClient.saas.blob) →
browser download, mirroring the quote-PDF pattern.
- MSW mock: sign sets the version and serves a minimal PDF blob.
Note: the stored PDF only exists when the render runtime (WeasyPrint) was
available at signing; without it the signature still records but there's no
PDF, so the button correctly won't show.
Verified: saas compiles; portal typecheck, eslint, prettier, toml-sort,
translation audits, 174 portal tests.
LegalConsent + LegalConsentRepository live in stirling.software.saas.legal,
which SaasJpaConfig didn't scan, so the app failed to start
(No qualifying bean of type LegalConsentRepository). Add the package to
@EnableJpaRepositories and @EntityScan.
The frontend dev tasks set a cosmetic STIRLING_DEV_LABEL via `basename`,
which isn't on PATH under go-task's shell on Windows PowerShell, so
`task frontend:dev:*` failed with exit 127. Use POSIX parameter expansion
(${dir##*/}), which go-task's built-in shell supports on every platform.
Builds on the versioned legal registry from the agreement PR.
Legal document viewing (P3/P4)
- GET /api/v1/legal/{docId} serves any registered doc (EULA, SLA exhibit,
subprocessors) as markdown from the registry; a LegalDocumentModal renders
it in-product with a draft badge. The SLA exhibit is one of these docs, so
the Order Form's "per SLA Exhibit" reference is now viewable.
Consent logging (P3)
- New legal_consent table (V39 + Supabase twin) records clickwrap consents
pinned to the exact document id + version. POST /api/v1/legal/consent.
- The trial-setup modal now requires accepting the EULA (with a link to read
it) before starting, and the quote builder records EULA consent on generate.
Best-effort — consent logging never blocks the flow.
Enhanced IP Protection (P2, light)
- Relabel the add-on to "Enhanced IP Protection" (patent coverage) and note
that baseline IP indemnification is included free. The Order Form already
renders it as elected/not from the agreement PR. Copy-only, no pricing change.
Out of scope (noted): self-serve PAYG / prepay consent surfaces don't exist
yet (separate self-serve billing workstream); a redundant quote-level legal-
entity column (signing already captures the authoritative legal name).
Verified: saas compiles; portal typecheck, eslint, prettier, dpdm (no cycles),
toml-sort, translation audits, and 176 portal/audit vitest tests pass.
Replaces the placeholder agreement stub with the full Stirling Enterprise
Agreement (MSA + Order Form + DPA, one signature), served from a new
versioned legal-document registry, and captures a real signature record.
Versioned legal registry (backend-owned)
- app/saas/.../resources/legal/manifest.json + legal/<id>/<version>/*.md for
the agreement (MSA + DPA), EULA, SLA exhibit and subprocessors, cleaned of
internal counsel markers. Publishing a new version = drop a markdown file +
bump the manifest; no code change.
- LegalDocumentRegistry loads the manifest and fills {{token}} slots.
Agreement rendering + signing
- AgreementAssembler builds MSA + a generated Order Form (Part B, from the
quote) + DPA, filling every token; provider signatory is Matt Joseph, CEO.
- GET /procurement/agreement/document serves the filled markdown; the portal
renders it (react-markdown) with a scroll-to-sign gate and typed legal
name / signatory / title / authority.
- POST /procurement/agreement/sign records an immutable signature pinned to
the document id + version + a SHA-256 content hash + the variable snapshot,
then the flow accepts the quote as before.
Signed PDF
- AgreementPdfRenderer dogfoods Stirling's own Markdown->HTML->PDF path
(commonmark + common FileToPdf/WeasyPrint); resilient — the signature is
recorded even if the render runtime is unavailable, and the PDF is served
from GET /procurement/agreement/signature/pdf when stored.
Storage: procurement_agreement_signature (V38 + Supabase twin). Read-only
pricing accessors added for the Order Form (effective rate/PDF, term
discount %); no billing behaviour change. Pricing reconciliation (25 MB
data-processing model) is intentionally out of scope. Legal text is DRAFT,
attorney-review-required — surfaced with a draft badge.
# Description of Changes
- Replaced the Tauri application icon with an RGBA-formatted PNG.
- Added a root `.imgbotconfig` that excludes the Tauri icon from
automatic image optimization.
- Fixed the `desktop:test` compilation failure caused by
`tauri::generate_context!()` rejecting the previous non-RGBA icon.
- Prevented ImgBot from potentially converting the icon back to an
unsupported indexed PNG while optimizing its file size.
- Verified that the current icon uses PNG Color Type 6 (`Truecolour with
alpha`).
```sh
[desktop:test] error: proc macro panicked
[desktop:test] --> src/lib.rs:202:12
[desktop:test] |
[desktop:test] 202 | .build(tauri::generate_context!())
[desktop:test] | ^^^^^^^^^^^^^^^^^^^^^^^^^^
[desktop:test] |
[desktop:test] = help: message: icon /Users/runner/work/Stirling-PDF/Stirling-PDF/frontend/editor/src-tauri/icons/icon.png is not RGBA
[desktop:test]
[desktop:test] error: could not compile `***-pdf` (lib) due to 1 previous error
[desktop:test] warning: build failed, waiting for other jobs to finish...
[desktop:test] error: could not compile `***-pdf` (lib test) due to 1 previous error
task: Failed to run task "desktop:test": exit status 101
Error: exit status 101
```
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
# Description of Changes
Policies can currently throw when calling redact:
<img width="1186" height="824" alt="image"
src="https://github.com/user-attachments/assets/bdcc09fe-5bf4-4b0a-b119-bcc33c98c7f2"
/>
Policies really need to be updated to properly make use of the new
bidirectional mappings for this, but this will hopefully fix it for now.
The external-link "Developer Tools" buttons (API, Automated Folder
Scanning, SSO Guide, Air-gapped Setup) used `p="sm"` while normal tool
buttons use `p="none"`, making them render larger; this aligns their
padding so they match the size of every other tool button.
<img width="308" height="196" alt="Screenshot 2026-07-10 at 5 01 40 PM"
src="https://github.com/user-attachments/assets/fb125500-28fb-4b83-85ed-2edc12e66fc0"
/>
## What this does
Adds one test to `ResourceAccessServiceTest`: a foreign team's lead is
**denied** on a team-owned resource under the `ADMINS_AND_TEAM_LEADS`
default policy, even when an unscoped `isAnyTeamLeader` check would
admit them (stubbed `lenient()` to `true` precisely so the test fails if
the scoped path ever consults it again).
## Why
Main is already correct here — no behaviour changes in this PR. #6913
landed the scoped implementation (`matchesTeamLeadDefault`: ownerless
portal → `isAnyTeamLeader`, team-owned → `isLeaderOfTeam`), which
superseded #6893. The only piece not carried over was #6893's boundary
test, so the cross-team scoping isn't currently pinned by any test. This
adds that pin as cheap insurance for future refactors.
Verified the test does its job: it passes on main as-is, and fails if
the scoped check is swapped back to the unscoped one.
## Test plan
- `:proprietary:test --tests
"stirling.software.proprietary.access.service.ResourceAccessServiceTest"`
— green
- Spotless applied
Closes the loop on #6893.
Reworks the Processor (PAYG) meter to **size-scaled units** while
keeping a true **PDF count** visible and distinct from units, and
replaces the fragile content+time lineage grouping with **explicit
per-run grouping**. Built as one PR across three slices.
> Status: **all three slices committed + verified.** `:saas` payg suite
green (418 tests, 0 failures); FE green (typecheck 0, 1260 tests, lint
0, format 0). Remaining before it takes effect in prod: run the
size-scaled default-policy SQL (below) in the Supabase SQL editor +
attach the $0.01/unit Stripe price.
## Model (what we're implementing)
- **Size scaling**: 1 unit per 50 MiB (bytes only, no page charge, no
cap). *(policy-row config, applied separately via SQL.)*
- **Charge = number of input files**: split (1→N outputs) = 1 charge;
merge (N→1) = N charges. `doc_count` = input files, fixed at open;
joined steps add 0.
- **Grouping by run id, not time**: a pipeline/policy/AI run = one
`run_id`; its tool sub-steps group into one charge (content-lineage
still maps split/merge journeys *within* the run). Two separate runs on
identical bytes = two charges. The 5-min window survives only as a
stale-job janitor.
- **10-tool split kept**: within a run's single-file lineage, an 11th
tool run opens a 2nd charge (step limit 10).
- **Count vs units surfaced**: usage page shows unique PDFs,
per-category (automation/AI/API) counts + units, and how many PDFs hit a
size multiplier with avg units/PDF.
## Slice 1 — run-id grouping (behavioural core)
- `AutomationRunContext` (common) — thread-scoped run id.
- `InternalApiClient` — stamps `X-Stirling-Run-Id`.
- Orchestrators open a run scope **on the worker thread that
dispatches** (async-safe): `PipelineProcessor.runPipelineAgainstFiles`,
`PolicyEngine.runToCompletion` (uses `run.getRunId()`),
`AiWorkflowService.orchestrate`.
- `ChargeContext` + `JobContext`: add `runId`; the charge interceptor
reads the header.
- `JobService.joinOrOpen`: `runId == null` → always open fresh
(standalone never joins); non-null → match scoped to the same `run_id`.
`JpaJobLineageStore`/`JobArtifactHashRepository`: add `run_id` filter to
the match query. Step-limit 10 unchanged.
## Slice 2 — doc_count + document_fingerprint
- V33 migration + entity fields.
- `JobService.openFresh`: set `docCount = inputs.size()`, compute
`document_fingerprint` from input signatures, and denormalise both onto
the DEBIT row in `JobChargeService.recordLedgerDebit`.
## Slice 3 — usage analytics API + FE
- `WalletLedgerRepository`: per-category `SUM(units)` +
`SUM(doc_count)`, `COUNT(DISTINCT document_fingerprint)`, and count of
rows whose units exceed their doc_count (a size multiplier fired), over
the period.
- `WalletSnapshotResponse` + `PaygWalletController`: add `categoryDocs`,
`docsProcessedThisPeriod`, `uniquePdfsThisPeriod`,
`sizeMultiplierPdfsThisPeriod`.
- FE `types.ts` + `PdfsProcessedCard` + `useWallet` + `walletFixtures` +
i18n: headline is the **PDF count**; a summary line shows "{unique}
unique · {units} meter units · {avg} avg units/PDF"; the split bar is
per-category PDF counts; a size-multiplier line shows how many PDFs
scaled. Count is separated from meter units so a 5-unit large PDF reads
as "1 PDF, 5 units".
## Config (out of PR — run in the Supabase SQL editor)
Wrap in one transaction. The partial-unique `is_default` index only
allows one default, so the old default is flipped off **before** the new
one is inserted. The new policy carries the prior default's
`free_tier_units` forward (change the literal if the launch grant should
differ).
```sql
BEGIN;
-- 1) flip default off the current policy + close its effective window
UPDATE stirling_pdf.pricing_policy
SET is_default = FALSE, effective_to = now()
WHERE is_default = TRUE;
-- 2) new default: 1 unit / 5 MiB, no page charge, no scaling cap.
-- free_tier_units carried from whatever the last policy granted (COALESCE→0).
INSERT INTO stirling_pdf.pricing_policy
(version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
min_charge_units, file_unit_cap, free_tier_units, is_default, notes, created_by)
VALUES
('v2-size-scaled-2026-07', now(),
2147483647, -- doc_pages_per_unit = INT_MAX → pages never drive units
52428800, -- doc_bytes_per_unit = 50 MiB → +1 unit per 50 MiB
1, -- min_charge_units
2147483647, -- file_unit_cap = INT_MAX → no cap on size scaling
COALESCE((SELECT free_tier_units FROM stirling_pdf.pricing_policy
ORDER BY effective_from DESC LIMIT 1), 0),
TRUE, 'Size-scaled: 1 unit/5MiB, bytes only, no cap', 'connor');
-- 3) per-source step limits: standalone ops = own charge; pipelines split at 10
INSERT INTO stirling_pdf.pricing_policy_step_limit (policy_id, job_source, step_limit)
SELECT p.policy_id, s.src, s.lim
FROM stirling_pdf.pricing_policy p
CROSS JOIN (VALUES
('WEB',1),('API',1),('DESKTOP_APP',1),('LINKED_INSTANCE',1),('PIPELINE',10)
) AS s(src, lim)
WHERE p.version = 'v2-size-scaled-2026-07';
-- 4) attach the $0.01/unit Stripe price (you handle the real price id)
INSERT INTO stirling_pdf.pricing_policy_stripe_price (policy_id, stripe_price_id)
SELECT policy_id, 'price_XXXXXXXX'
FROM stirling_pdf.pricing_policy WHERE version = 'v2-size-scaled-2026-07';
COMMIT;
```
Note: the `free_tier_units` subquery reads the most-recent policy
*before* the insert — run it as written (the new row doesn't exist yet
at step 2's SELECT).
## Self-hosted parity — tracked follow-up (not in this PR)
Combined-billing (`stirling.billing.account-link.enabled`) is a
**separate metering engine** (`app/proprietary/accountlink` —
`UsageMeterService`/`LocalUsageService`/`UsageSyncService`). The unit
*math* is shared (`DocumentUnitCalculator`), so size scaling matches
once the policy is pushed. But run-id grouping, `doc_count`, and
fingerprints must be mirrored there, and the usage-sync protocol
extended to report counts/fingerprints, before the self-hosted usage
page shows the same breakdown. Frozen/deferred, so this PR does SaaS;
self-hosted mirrors when it ships.
## What
Phase 2 of the Free PDF Editors usage card (self-hosted shipped in
#6919): make it work on **SaaS**, where one backend serves many teams so
every figure must be scoped to the **caller's team**.
| Metric | SaaS (per team) |
|---|---|
| **Editors deployed** | team member count (`team_memberships`) |
| **Active this month** | distinct members with a free-UI
(`source='WEB'`, non-`UI_DATA`) audit event in 30d, clamped ≤ deployed |
| **PDFs edited** | the team's cumulative free-UI
`PDF_PROCESS`+`FILE_OPERATION` events |
Cost stays `$0`; uncomputable figures render **N/A**.
## Backend
- **Gate the self-hosted controller** `@Profile("!saas")` — its counts
are server-wide, which would leak across tenants on SaaS. New
team-scoped `SaasFleetUsageController` `@Profile("saas")` owns the same
`/api/v1/usage/fleet-stats` path (mutually exclusive profiles → no
mapping conflict).
- **Team resolution** mirrors `PaygWalletController`:
`AuthenticationUtils.getCurrentUser(auth, userRepo)` →
`TeamMembershipRepository.findPrimaryMembership` → members via
`findByTeamId`. `@PreAuthorize("isAuthenticated()")` (team leaders
aren't global admins; any member sees their own team's totals).
- **Audit → team join**: on SaaS the audit `principal` is the user's
email and `User.username == email`, so principals join cleanly to a
team's member usernames (no hashing — only raw-JWT/over-long principals
get hashed). Two new `principal IN` count queries do the filtering,
served by the `(source, timestamp, principal)` index from #6919.
- Billing/ledger is deliberately **not** used — it only records billable
ops; free-editor activity comes from audit (same `source='WEB'` signal
as self-hosted).
- `null`→N/A when EE auditing < STANDARD; 401 on no-auth; empty-fleet
guard for the (post-migration-shouldn't-happen) teamless caller.
## Frontend
- New `src/portal-saas/api/fleetStats.ts` (rides the `@portal/*` cascade
from #6900) reads via **`apiClient.saas`** — the Supabase JWT the SaaS
backend uses to resolve the team. Re-exports `FleetStats` via
`@portal-proprietary`. **The card and `useAsync` hook are untouched.**
## Tests
`STIRLING_FLAVOR=saas` build green — `:proprietary` + `:saas` compile,
`SaasFleetUsageControllerTest` (team scoping, audit-off→null, clamp,
no-team→empty, unauth→401) and the existing suites pass; spotless clean.
## Notes
- Requires SaaS auditing at STANDARD (it is) — else N/A.
- Depends on #6900 (merged) for the portal-saas override layer and #6919
(merged) for the audit `source` column + DTO.
# Description of Changes
Adds Editor source permanently available in the Sources list. Excludes
it from the Pipelines list of available sources currently because it's
not a real source on the backend, so attempting to connect to it causes
an error. It'd be nice to extend in the future to be able to set up
policies in the editor from the pipelines page, but this'll do for now.
## What
Reworks the free-tier home hero (`WelcomeBanner` + `SetupChecklist`) to
match marketing's reworked top card: a compact product header over
numbered getting-started steps, dropping the marketing chrome.
## aim
attachments/assets/75a80e5f-119e-46bb-80e7-fc4b9a62e5b6" />
<img width="1098" height="646" alt="01-aim-marketing-demo"
src="https://github.com/user-attachments/assets/681ca1b8-219e-4afe-9748-89435aafd440"
/>
## old hero
<img width="1800" height="1338" alt="02-before-old-hero"
src="https://github.com/user-attachments/assets/a396cec0-4752-4ac0-9951-9a49f9d50ea7"
/>
## new screenshots
<img width="1800" height="626" alt="03-after-onboarding-card"
src="https://github.com/user-attachments/assets/59332111-6c49-438d-af6d-200d99bf0f8f"
/>
<img width="1800" height="180" alt="04-after-deployed-header"
src="https://github.com/user-attachments/assets/ea631e6c-7878-4159-aaf7-1f0c2bd795ce"
/>
<img width="1024" height="1396" alt="05-after-install-modal-list"
src="https://github.com/user-attachments/assets/125d0a11-d799-40f0-bd8b-42f5dedcfe6d"
/>
<img width="1024" height="858" alt="06-after-install-modal-docker"
src="https://github.com/user-
## Changes
- **Compact dark header:** brand mark + "PDF Editor" + social-proof
stats (`30M downloads · 60+ PDF operations · Free forever`) + a single
**Open in browser** CTA (→ `EDITOR_URL`).
- **Dropped** the decorative editor mock, marketing
title/subtitle/"Open-source" badge/perks, the two extra banner buttons,
and the checklist's dismiss/progress/done tracking.
- **Numbered nav steps** (①②③) — each opens its in-app surface:
| # | Step | Goes to | Change |
|---|------|---------|--------|
| ① | Download the editor | `editor` view | was an external
`stirling.com/download` link → now in-app |
| ② | Confirm your policies | `policies` view | live active/recommended
counts retained |
| ③ | Invite teammates | `users` view | **replaces** "Connect your
sources" (sources dropped to match the demo) |
- **Enterprise rung** unchanged (Start Trial / Get Quote → procurement).
## Notes
- **Shared hero** — self-hosted sees it too (per decision).
- **One deliberate deviation from the demo:** the header CTA is blue
(brand primary) rather than the demo's white button. Trivial to flip —
say the word.
- Behaviour change: the hero is now a quick-start (navigational) rather
than a completion checklist — the dismiss control + per-step done chips
are gone to match the demo.
- Supersedes the incremental #6944 ("add Open in browser" 3-button
version) — that can be closed in favour of this.
- Portal `tsc` clean; `unusedTranslations` green (removed orphaned
welcome/onboarding keys, added the new ones).
Removes some cluttered/unused UI from the portal:
- Search bar in the header
- The top bar entirely (breadcrumb, notification bell, plan switcher,
user menu)
- The plan/usage indicator in the sidebar footer
- The floating assistant badge
UI only. Where a component isn't deleted it's just no longer rendered,
so anything here is easy to restore.
## What
The portal's **"What runs on your PDFs"** table (`PolicySummary`)
rendered raw i18n keys instead of text:
- `portal.policies.categories.ingestion.label` / `.desc`
- `portal.policies.categories.security.label` / `.desc`
- …and the other three categories (compliance, routing, retention)
## Why it broke
[#6910 "Remove in-app portal
mocks"](https://github.com/Stirling-Tools/Stirling-PDF/pull/6910) moved
the policy catalogue to real data and converted each category's
`label`/`desc` (and each config's `summary`) into **i18n keys** — see
the `// values are i18n keys — render with t()` note in
`api/policies.ts`. Every consumer was updated to call `t()`
(`PolicyCategoryCard`, `PolicyDetailPanel`, `PolicySetupWizard`)… except
`PolicySummary`, which was not part of that PR and kept rendering the
fields verbatim.
The translation keys themselves already exist in
`en-US/translation.toml` (`[portal.policies.categories.*]`) — nothing
was missing, they just weren't being looked up.
## Fix
Wrap the values in `t()` in `PolicySummary.tsx` (the `t` from
`useTranslation` was already in scope):
- category `label` / `desc` in the Policy column
- `config.summary` in the Active-rule column (same keyed-value
treatment, latent until a policy is active)
## Test plan
- [ ] Open the portal Home / policies summary → each row shows the
translated category name + description (e.g. "Ingestion" / "Classify
documents…") instead of a dotted key.
- [ ] A row with an active policy shows its translated rule summary in
the Active rule column.
## What
Removes the policy **management** surface from the editor's right rail —
the Policies list above Tools, the open-policy detail takeover, and the
collapsed-rail policy icons — along with the whole UI tree only they
used: the setup wizard and its tool-config steps (PII / redact /
watermark), the detail panel, delete modal, selection store,
enforcement-queue status chip, activity/stats derivation, the catalog
hook, their i18n keys, dead types, and the admin-gate spec that tested
the wizard flow.
**Enforcement is untouched.** Auto-run on upload, the viewer blocking
overlay, exit-point blocking, file badges, and export-time enforcement
all stay. `usePoliciesEnabled` moves to its own module (core stub /
proprietary / desktop shadow with the SaaS-connection check) since it
still gates mounting the headless `PolicyAutoRunController` from the
rail.
## Why
Policies are configured in the admin portal now
(`src/portal/views/Policies.tsx`). Keeping a second management UI in the
editor rail meant two surfaces to maintain for one feature; the editor
only needs to *enforce*.
## Notes for review
- The rail UI lived in the shared `core` `RightSidebar`, so this removes
it from every build flavour at once; the deleted `PoliciesSidebar`
module existed at the core (stub) / proprietary / desktop alias layers
and all three are gone.
- Every deleted module was verified to have zero remaining importers;
near-misses that stay: `enforcementQueue` (used by export enforcement),
`poll` (test-imported), `usePolicies` (used by auto-run).
- Net −3,900 lines.
## Testing
- `task frontend:check` green: typecheck, ESLint + dpdm, Prettier, all
1,196 tests.
- All build-variant typechecks pass (core / proprietary / saas /
desktop).